Skip to content

Commit daa02d3

Browse files
mpivchevi2h3
andauthored
Assistant design improvements + V2 API (#3327)
* WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * refactor Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * Change CI code Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * UI tests Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * Refactor date Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * Refacgtor Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * Refactor Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * PR changes Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * Update Tests/NextcloudUITests/AssistantUITests.swift Co-authored-by: Iva Horn <iva.horn@icloud.com> Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * PR fixes Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * PR fixes 2 Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * PR fixes 3 Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> --------- Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> Co-authored-by: Iva Horn <iva.horn@icloud.com>
1 parent 043e018 commit daa02d3

33 files changed

Lines changed: 855 additions & 459 deletions

Nextcloud.xcodeproj/project.pbxproj

Lines changed: 41 additions & 17 deletions
Large diffs are not rendered by default.

Share/NCShareExtension+DataSource.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ extension NCShareExtension: UICollectionViewDataSource {
154154
cell.imageItem.image = NCImageCache.shared.getFolder(account: metadata.account)
155155
}
156156

157-
cell.labelInfo.text = utility.dateDiff(metadata.date as Date)
157+
cell.labelInfo.text = utility.getRelativeDateTitle(metadata.date as Date)
158158

159159
let lockServerUrl = utilityFileSystem.stringAppendServerUrl(metadata.serverUrl, addFileName: metadata.fileName)
160160
let tableDirectory = self.database.getTableDirectory(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, lockServerUrl))
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// SPDX-FileCopyrightText: Nextcloud GmbH
2+
// SPDX-FileCopyrightText: 2025 Iva Horn
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
import XCTest
6+
7+
///
8+
/// User interface tests for the download limits management on shares.
9+
///
10+
@MainActor
11+
final class AssistantUITests: BaseUIXCTestCase {
12+
let taskInputCreated = "TestTaskCreated" + UUID().uuidString
13+
let taskInputRetried = "TestTaskRetried" + UUID().uuidString
14+
let taskInputToEdit = "TestTaskToEdit" + UUID().uuidString
15+
let taskInputDeleted = "TestTaskDeleted" + UUID().uuidString
16+
17+
// MARK: - Lifecycle
18+
19+
override func setUp() async throws {
20+
try await super.setUp()
21+
continueAfterFailure = false
22+
23+
// Handle alerts presented by the system.
24+
addUIInterruptionMonitor(withDescription: "Allow Notifications", for: "Allow")
25+
addUIInterruptionMonitor(withDescription: "Save Password", for: "Not Now")
26+
27+
// Launch the app.
28+
app = XCUIApplication()
29+
app.launchArguments = ["UI_TESTING"]
30+
app.launch()
31+
32+
try await logIn()
33+
34+
// Set up test backend communication.
35+
backend = UITestBackend()
36+
37+
try await backend.assertCapability(true, capability: \.assistant)
38+
}
39+
40+
///
41+
/// Leads to the Assistant screen.
42+
///
43+
private func goToAssistant() {
44+
let button = app.tabBars["Tab Bar"].buttons["More"]
45+
guard button.await() else { return }
46+
button.tap()
47+
48+
let talkStaticText = app.tables.staticTexts["Assistant"]
49+
talkStaticText.tap()
50+
}
51+
52+
private func createTask(input: String) {
53+
app.navigationBars["Assistant"].buttons["CreateButton"].tap()
54+
55+
let inputTextEditor = app.textViews["InputTextEditor"]
56+
inputTextEditor.await()
57+
inputTextEditor.typeText(input)
58+
app.navigationBars["New Free text to text prompt task"].buttons["Create"].tap()
59+
}
60+
61+
private func retryTask() {
62+
let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0)
63+
cell.staticTexts[taskInputRetried].press(forDuration: 2);
64+
65+
let retryButton = app.buttons["TaskRetryContextMenu"]
66+
XCTAssertTrue(retryButton.waitForExistence(timeout: 2), "Edit button not found in context menu")
67+
retryButton.tap()
68+
}
69+
70+
private func editTask() {
71+
let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0)
72+
cell.staticTexts[taskInputToEdit].press(forDuration: 2);
73+
74+
let editButton = app.buttons["TaskEditContextMenu"]
75+
XCTAssertTrue(editButton.waitForExistence(timeout: 2), "Edit button not found in context menu")
76+
editButton.tap()
77+
78+
app.textViews["InputTextEditor"].typeText("Edited")
79+
app.navigationBars["Edit Free text to text prompt task"].buttons["Edit"].tap()
80+
}
81+
82+
private func deleteTask() {
83+
let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0)
84+
cell.staticTexts[taskInputDeleted].press(forDuration: 2);
85+
86+
let deleteButton = app.buttons["TaskDeleteContextMenu"]
87+
XCTAssertTrue(deleteButton.waitForExistence(timeout: 2), "Edit button not found in context menu")
88+
deleteButton.tap()
89+
90+
app.sheets.scrollViews.otherElements.buttons["Delete"].tap()
91+
}
92+
93+
// MARK: - Tests
94+
95+
func testCreateAssistantTask() async throws {
96+
goToAssistant()
97+
98+
createTask(input: taskInputCreated)
99+
100+
pullToRefresh()
101+
102+
try await aMoment()
103+
104+
let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0)
105+
XCTAssert(cell.staticTexts[taskInputCreated].exists)
106+
}
107+
108+
func testRetryAssistantTask() async throws {
109+
goToAssistant()
110+
111+
createTask(input: taskInputRetried)
112+
113+
retryTask()
114+
115+
pullToRefresh()
116+
117+
try await aMoment()
118+
119+
let matchingElements = app.collectionViews.cells.staticTexts.matching(identifier: taskInputRetried)
120+
print(app.collectionViews.staticTexts.debugDescription)
121+
XCTAssertEqual(matchingElements.count, 2, "Expected 2 elements")
122+
}
123+
124+
func testEditAssistantTask() async throws {
125+
goToAssistant()
126+
127+
createTask(input: taskInputToEdit)
128+
129+
editTask()
130+
131+
pullToRefresh()
132+
133+
try await aMoment()
134+
135+
let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0)
136+
XCTAssert(cell.staticTexts[taskInputToEdit + "Edited"].exists)
137+
}
138+
139+
func testDeleteAssistantTask() async throws {
140+
goToAssistant()
141+
142+
createTask(input: taskInputDeleted)
143+
144+
deleteTask()
145+
146+
pullToRefresh()
147+
148+
try await aMoment()
149+
150+
let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0)
151+
XCTAssert(!cell.staticTexts[taskInputDeleted].exists)
152+
}
153+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// SPDX-FileCopyrightText: Nextcloud GmbH
2+
// SPDX-FileCopyrightText: 2025 Milen Pivchev
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
import Foundation
6+
import XCTest
7+
8+
@MainActor
9+
class BaseUIXCTestCase: XCTestCase {
10+
var app: XCUIApplication!
11+
12+
///
13+
/// The Nextcloud server API abstraction object.
14+
///
15+
var backend: UITestBackend!
16+
17+
///
18+
/// Generic convenience method to define user interface interruption monitors.
19+
///
20+
/// This is called every time an alert from outside the app's user interface is presented (in example system prompt about saving a password).
21+
/// Then the button is tapped defined by the given `label`.
22+
///
23+
/// - Parameters:
24+
/// - description: The human readable description for the monitor to create.
25+
/// - label: The localized text on the alert action to tap.
26+
///
27+
///
28+
func addUIInterruptionMonitor(withDescription description: String, for label: String) {
29+
addUIInterruptionMonitor(withDescription: description) { alert in
30+
let button = alert.buttons[label]
31+
32+
if button.exists {
33+
button.tap()
34+
return true
35+
}
36+
37+
return false
38+
}
39+
}
40+
41+
///
42+
/// Let the current `Task` rest for 2 seconds.
43+
///
44+
/// Some asynchronous background activities like the follow up request to define a download limit have no effect on the visible user interface.
45+
/// Hence their outcome can only be assumed after a brief period of time.
46+
///
47+
func aSmallMoment() async throws {
48+
try await Task.sleep(for: .seconds(2))
49+
}
50+
51+
///
52+
/// Let the current `Task` rest for ``TestConstants/controlExistenceTimeout``.
53+
///
54+
/// Some asynchronous background activities like the follow up request to define a download limit have no effect on the visible user interface.
55+
/// Hence their outcome can only be assumed after a brief period of time.
56+
///
57+
func aMoment() async throws {
58+
try await Task.sleep(for: .seconds(TestConstants.controlExistenceTimeout))
59+
}
60+
61+
///
62+
/// Automation of the sign-in, if required.
63+
///
64+
///
65+
func logIn() async throws {
66+
guard app.buttons["login"].exists else {
67+
return
68+
}
69+
70+
app.buttons["login"].tap()
71+
72+
let serverAddressTextField = app.textFields["serverAddress"].firstMatch
73+
guard serverAddressTextField.await() else { return }
74+
75+
serverAddressTextField.tap()
76+
serverAddressTextField.typeText(TestConstants.server)
77+
78+
app.buttons["submitServerAddress"].tap()
79+
80+
let webView = app.webViews.firstMatch
81+
82+
guard webView.await() else {
83+
throw UITestError.waitForExistence(webView)
84+
}
85+
86+
let loginButton = webView.buttons["Log in"]
87+
88+
if loginButton.await() {
89+
loginButton.tap()
90+
}
91+
92+
let usernameTextField = webView.textFields.firstMatch
93+
94+
if usernameTextField.await() {
95+
guard usernameTextField.await() else { return }
96+
usernameTextField.tap()
97+
usernameTextField.typeText(TestConstants.username)
98+
99+
let passwordSecureTextField = webView.secureTextFields.firstMatch
100+
passwordSecureTextField.tap()
101+
passwordSecureTextField.typeText(TestConstants.password)
102+
103+
webView.buttons.firstMatch.tap()
104+
}
105+
106+
try await aSmallMoment()
107+
108+
let grantButton = webView.buttons["Grant access"]
109+
110+
guard grantButton.await() else {
111+
throw UITestError.waitForExistence(grantButton)
112+
}
113+
114+
grantButton.tap()
115+
grantButton.awaitInexistence()
116+
117+
app.buttons["accountSwitcher"].await()
118+
119+
try await aSmallMoment()
120+
}
121+
122+
///
123+
/// Pull to refresh on the first found collection view to reveal the new file on the server.
124+
///
125+
func pullToRefresh(file: StaticString = #file, line: UInt = #line) {
126+
let cell = app.collectionViews.firstMatch.staticTexts.firstMatch
127+
128+
guard cell.exists else {
129+
XCTFail("Apparently no collection view cell is visible!", file: file, line: line)
130+
return
131+
}
132+
133+
let start = cell.coordinate(withNormalizedOffset: CGVectorMake(0, 0))
134+
let finish = cell.coordinate(withNormalizedOffset: CGVectorMake(0, 20))
135+
136+
start.press(forDuration: 0.2, thenDragTo: finish)
137+
}
138+
}

0 commit comments

Comments
 (0)