← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


Tests/UI/ListlessMacUITests.swift (16143B)

1import CoreGraphics
2import XCTest
3
4final class ListlessMacUITests: XCTestCase {
5    var app: XCUIApplication!
6
7    override func setUpWithError() throws {
8        continueAfterFailure = false
9        app = XCUIApplication()
10        app.launchArguments = ["UI_TESTING"]
11        app.launch()
12    }
13
14    override func tearDownWithError() throws {
15        app.terminate()
16    }
17
18    // MARK: - Helpers
19
20    /// The "Click to create" empty state label.
21    var emptyStateLabel: XCUIElement {
22        app.staticTexts["Click to create"]
23    }
24
25    /// The draft row text field that appears after Cmd+N.
26    var draftTextField: XCUIElement {
27        app.textFields["draft-row-append"]
28    }
29
30    /// Returns the text field for a committed item with the given title.
31    func itemText(_ title: String) -> XCUIElement {
32        app.textFields.matching(
33            NSPredicate(format: "identifier BEGINSWITH 'item-text-' AND value == %@", title)
34        ).firstMatch
35    }
36
37    /// Creates a item by typing into the draft field and pressing Return.
38    /// If no draft field exists yet, presses Cmd+N to create one.
39    func createItem(_ title: String) {
40        let textField = draftTextField
41        if !textField.exists {
42            app.typeKey("n", modifierFlags: .command)
43            if !textField.waitForExistence(timeout: 2) {
44                XCTFail("Draft text field should appear after Cmd+N")
45                return
46            }
47        }
48        textField.click()
49        textField.typeText(title)
50        textField.typeKey(.return, modifierFlags: [])
51    }
52
53    /// Returns the Nth checkbox button (0-indexed).
54    func itemCheckbox(at index: Int) -> XCUIElement {
55        app.buttons.matching(identifier: "item-checkbox").element(boundBy: index)
56    }
57
58    /// Enters navigation mode by pressing Escape, then navigates to the item at
59    /// the given position (0-indexed from the top) using arrow keys.
60    func navigateToItem(at index: Int) {
61        app.typeKey(.escape, modifierFlags: [])
62        for _ in 0...index {
63            app.typeKey(.downArrow, modifierFlags: [])
64        }
65    }
66
67    /// Performs a Command+Click on the row containing the given item title.
68    /// Uses CGEvent mouse events with `.maskCommand` so the app sees the
69    /// modifier via `NSApp.currentEvent?.modifierFlags`. Clicks in the
70    /// row's left padding area (before the checkbox) so the tap gesture
71    /// fires rather than the text field or checkbox.
72    func cmdClickRow(withText title: String) {
73        let textField = itemText(title)
74        XCTAssertTrue(textField.waitForExistence(timeout: 2))
75        // Offset to the left of the text field, into the row's 16pt left padding.
76        let coord = textField.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5))
77            .withOffset(CGVector(dx: -40, dy: 0))
78        let point = coord.screenPoint
79
80        let src = CGEventSource(stateID: .combinedSessionState)
81
82        let mouseDown = CGEvent(
83            mouseEventSource: src, mouseType: .leftMouseDown,
84            mouseCursorPosition: point, mouseButton: .left
85        )!
86        mouseDown.flags = .maskCommand
87        mouseDown.post(tap: .cgSessionEventTap)
88        usleep(50_000)
89
90        let mouseUp = CGEvent(
91            mouseEventSource: src, mouseType: .leftMouseUp,
92            mouseCursorPosition: point, mouseButton: .left
93        )!
94        mouseUp.flags = .maskCommand
95        mouseUp.post(tap: .cgSessionEventTap)
96        usleep(200_000)
97    }
98
99    // MARK: - Empty State
100
101    func testLaunchShowsEmptyState() {
102        XCTAssertTrue(
103            emptyStateLabel.waitForExistence(timeout: 2),
104            "Empty state label should be visible on launch"
105        )
106    }
107
108    func testEmptyStateDisappearsAfterCreatingItem() {
109        createItem("First item")
110        app.typeKey(.escape, modifierFlags: [])
111        XCTAssertFalse(emptyStateLabel.exists, "Empty state should disappear after creating a item")
112    }
113
114    // MARK: - Item Creation
115
116    func testCmdNFocusesDraftField() {
117        app.typeKey("n", modifierFlags: .command)
118        let textField = draftTextField
119        XCTAssertTrue(
120            textField.waitForExistence(timeout: 2),
121            "Draft text field should appear after Cmd+N"
122        )
123        // Type directly without clicking — verifies the field has focus
124        textField.typeText("Focused item")
125        textField.typeKey(.return, modifierFlags: [])
126        XCTAssertTrue(
127            itemText("Focused item").waitForExistence(timeout: 2),
128            "Typing without clicking should commit the item if draft field has focus"
129        )
130    }
131
132    func testCreateItemViaMenuShortcut() {
133        createItem("Buy groceries")
134        XCTAssertTrue(
135            itemText("Buy groceries").waitForExistence(timeout: 2),
136            "Item should appear with the typed title"
137        )
138    }
139
140    func testReturnChainsNewItem() {
141        createItem("First item")
142        XCTAssertTrue(
143            draftTextField.waitForExistence(timeout: 2),
144            "New draft text field should appear after Return"
145        )
146    }
147
148    func testCreateMultipleItems() {
149        createItem("Alpha")
150        createItem("Bravo")
151        createItem("Charlie")
152        app.typeKey(.escape, modifierFlags: [])
153
154        XCTAssertTrue(itemText("Alpha").waitForExistence(timeout: 2))
155        XCTAssertTrue(itemText("Bravo").exists)
156        XCTAssertTrue(itemText("Charlie").exists)
157    }
158
159    func testEmptyItemDeletedOnCommit() {
160        app.typeKey("n", modifierFlags: .command)
161        XCTAssertTrue(draftTextField.waitForExistence(timeout: 2))
162        app.typeKey(.escape, modifierFlags: [])
163        XCTAssertTrue(
164            emptyStateLabel.waitForExistence(timeout: 2),
165            "Empty state should reappear when empty item is discarded"
166        )
167    }
168
169    // MARK: - Item Completion
170
171    func testCompleteItemViaCheckbox() {
172        createItem("Finish report")
173        app.typeKey(.escape, modifierFlags: [])
174
175        let checkbox = itemCheckbox(at: 0)
176        XCTAssertTrue(checkbox.waitForExistence(timeout: 2))
177        XCTAssertEqual(checkbox.value as? String, "circle")
178        checkbox.click()
179
180        let completed = app.buttons.matching(
181            NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
182        ).firstMatch
183        XCTAssertTrue(
184            completed.waitForExistence(timeout: 3),
185            "Checkbox should show checkmark after clicking"
186        )
187    }
188
189    func testUncompleteItem() {
190        createItem("Finish report")
191        app.typeKey(.escape, modifierFlags: [])
192
193        itemCheckbox(at: 0).click()
194
195        let completed = app.buttons.matching(
196            NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
197        ).firstMatch
198        XCTAssertTrue(completed.waitForExistence(timeout: 3))
199        completed.click()
200
201        let uncompleted = app.buttons.matching(
202            NSPredicate(format: "identifier == 'item-checkbox' AND value == 'circle'")
203        ).firstMatch
204        XCTAssertTrue(
205            uncompleted.waitForExistence(timeout: 3),
206            "Checkbox should revert to circle after uncompleting"
207        )
208    }
209
210    // MARK: - Item Deletion
211
212    func testDeleteItemViaBackspace() {
213        createItem("Delete me")
214        navigateToItem(at: 0)
215        app.typeKey(.delete, modifierFlags: [])
216        XCTAssertTrue(
217            emptyStateLabel.waitForExistence(timeout: 2),
218            "Empty state should reappear after deleting the only item"
219        )
220    }
221
222    func testArrowKeyNavigationThenDelete() {
223        createItem("Keep me")
224        createItem("Delete me")
225        navigateToItem(at: 1)
226        app.typeKey(.delete, modifierFlags: [])
227
228        XCTAssertTrue(itemText("Keep me").waitForExistence(timeout: 2), "First item should remain")
229        XCTAssertFalse(itemText("Delete me").exists, "Second item should be deleted")
230    }
231
232    // MARK: - Reordering
233
234    func testMoveItemUp() {
235        createItem("Alpha")
236        createItem("Bravo")
237        navigateToItem(at: 1)
238        app.typeKey(.upArrow, modifierFlags: .command)
239
240        let bravo = itemText("Bravo")
241        let alpha = itemText("Alpha")
242        XCTAssertTrue(bravo.waitForExistence(timeout: 2))
243        XCTAssertTrue(alpha.exists)
244        XCTAssertLessThan(
245            bravo.frame.minY, alpha.frame.minY,
246            "Bravo should appear above Alpha after moving up"
247        )
248    }
249
250    func testMoveItemDown() {
251        createItem("Alpha")
252        createItem("Bravo")
253        navigateToItem(at: 0)
254        app.typeKey(.downArrow, modifierFlags: .command)
255
256        let alpha = itemText("Alpha")
257        let bravo = itemText("Bravo")
258        XCTAssertTrue(alpha.waitForExistence(timeout: 2))
259        XCTAssertTrue(bravo.exists)
260        XCTAssertGreaterThan(
261            alpha.frame.minY, bravo.frame.minY,
262            "Alpha should appear below Bravo after moving down"
263        )
264    }
265
266    // MARK: - Select All
267
268    func testSelectAllThenDelete() {
269        createItem("Alpha")
270        createItem("Bravo")
271        createItem("Charlie")
272        navigateToItem(at: 0)
273        app.typeKey("a", modifierFlags: .command)
274        app.typeKey(.delete, modifierFlags: [])
275        XCTAssertTrue(
276            emptyStateLabel.waitForExistence(timeout: 2),
277            "All items should be deleted after Select All + Delete"
278        )
279    }
280
281    func testSelectAllIncludesCompletedItems() {
282        createItem("Active item")
283        createItem("Done item")
284        app.typeKey(.escape, modifierFlags: [])
285
286        // Complete the second item
287        itemCheckbox(at: 1).click()
288        let completed = app.buttons.matching(
289            NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
290        ).firstMatch
291        XCTAssertTrue(completed.waitForExistence(timeout: 3))
292
293        // Navigate to first item, then select all and delete
294        navigateToItem(at: 0)
295        app.typeKey("a", modifierFlags: .command)
296        app.typeKey(.delete, modifierFlags: [])
297        XCTAssertTrue(
298            emptyStateLabel.waitForExistence(timeout: 2),
299            "Both active and completed items should be deleted after Select All + Delete"
300        )
301    }
302
303    // MARK: - Clear Completed
304
305    func testClearCompleted() {
306        createItem("Done item")
307        app.typeKey(.escape, modifierFlags: [])
308
309        itemCheckbox(at: 0).click()
310
311        let completed = app.buttons.matching(
312            NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
313        ).firstMatch
314        XCTAssertTrue(completed.waitForExistence(timeout: 3))
315
316        app.menuBars.menuBarItems["Edit"].click()
317        app.menuBars.menuBarItems["Edit"].menus.menuItems["Clear Completed"].click()
318
319        XCTAssertTrue(
320            emptyStateLabel.waitForExistence(timeout: 3),
321            "Empty state should reappear after clearing the only completed item"
322        )
323    }
324
325    // MARK: - Shift+Arrow Selection
326
327    func testShiftDownExtendsSelection() {
328        createItem("Alpha")
329        createItem("Bravo")
330        createItem("Charlie")
331        navigateToItem(at: 0)
332        app.typeKey(.downArrow, modifierFlags: .shift)
333        app.typeKey(.downArrow, modifierFlags: .shift)
334        // All three should be selected; delete removes them all.
335        app.typeKey(.delete, modifierFlags: [])
336        XCTAssertTrue(
337            emptyStateLabel.waitForExistence(timeout: 2),
338            "All three items should be deleted after Shift+Down range select"
339        )
340    }
341
342    func testShiftUpContractsSelection() {
343        createItem("Alpha")
344        createItem("Bravo")
345        createItem("Charlie")
346        navigateToItem(at: 0)
347        // Extend down to select Alpha, Bravo, Charlie
348        app.typeKey(.downArrow, modifierFlags: .shift)
349        app.typeKey(.downArrow, modifierFlags: .shift)
350        // Contract back up: deselect Charlie, then Bravo
351        app.typeKey(.upArrow, modifierFlags: .shift)
352        app.typeKey(.upArrow, modifierFlags: .shift)
353        // Only Alpha should be selected now.
354        app.typeKey(.delete, modifierFlags: [])
355        XCTAssertTrue(itemText("Bravo").waitForExistence(timeout: 2), "Bravo should remain")
356        XCTAssertTrue(itemText("Charlie").exists, "Charlie should remain")
357        XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted")
358    }
359
360    func testSelectAllThenShiftDown() {
361        createItem("Alpha")
362        createItem("Bravo")
363        createItem("Charlie")
364        navigateToItem(at: 0)
365        // Select all via Cmd+A
366        app.typeKey("a", modifierFlags: .command)
367        // Shift+Down should be a no-op (cursor already at last item).
368        // All three should still be selected.
369        app.typeKey(.downArrow, modifierFlags: .shift)
370        app.typeKey(.delete, modifierFlags: [])
371        XCTAssertTrue(
372            emptyStateLabel.waitForExistence(timeout: 2),
373            "All items should still be selected after Shift+Down at end"
374        )
375    }
376
377    // MARK: - Cmd+Click Selection
378
379    func testCmdClickDeselectsFromRange() {
380        createItem("Alpha")
381        createItem("Bravo")
382        createItem("Charlie")
383        navigateToItem(at: 0)
384        // Extend selection to all three
385        app.typeKey(.downArrow, modifierFlags: .shift)
386        app.typeKey(.downArrow, modifierFlags: .shift)
387        // Cmd+Click Bravo to deselect it
388        cmdClickRow(withText: "Bravo")
389        app.typeKey(.delete, modifierFlags: [])
390
391        XCTAssertTrue(
392            itemText("Bravo").waitForExistence(timeout: 2),
393            "Bravo should remain (was deselected by Cmd+Click)"
394        )
395        XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted")
396        XCTAssertFalse(itemText("Charlie").exists, "Charlie should be deleted")
397    }
398
399    func testCmdClickAddsToSelection() {
400        createItem("Alpha")
401        createItem("Bravo")
402        createItem("Charlie")
403        navigateToItem(at: 0)
404        // Cmd+Click Charlie to add it to selection
405        cmdClickRow(withText: "Charlie")
406        app.typeKey(.delete, modifierFlags: [])
407
408        XCTAssertTrue(
409            itemText("Bravo").waitForExistence(timeout: 2),
410            "Bravo should remain (was not selected)"
411        )
412        XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted")
413        XCTAssertFalse(itemText("Charlie").exists, "Charlie should be deleted")
414    }
415
416    func testShiftUpAfterCmdClickDeselect() {
417        createItem("Delta")
418        createItem("Echo")
419        createItem("Foxtrot")
420        createItem("Golf")
421        navigateToItem(at: 0)
422        // Select Delta through Golf
423        app.typeKey(.downArrow, modifierFlags: .shift)
424        app.typeKey(.downArrow, modifierFlags: .shift)
425        app.typeKey(.downArrow, modifierFlags: .shift)
426        // Cmd+Click Echo to deselect: {Delta, Foxtrot, Golf}
427        cmdClickRow(withText: "Echo")
428        // Shift+Up contracts from cursor end: removes Golf → {Delta, Foxtrot}
429        app.typeKey(.upArrow, modifierFlags: .shift)
430        app.typeKey(.delete, modifierFlags: [])
431
432        XCTAssertTrue(itemText("Echo").waitForExistence(timeout: 2), "Echo should remain")
433        XCTAssertTrue(itemText("Golf").exists, "Golf should remain")
434        XCTAssertFalse(itemText("Delta").exists, "Delta should be deleted")
435        XCTAssertFalse(itemText("Foxtrot").exists, "Foxtrot should be deleted")
436    }
437
438    func testSelectAllAfterCmdClick() {
439        createItem("Alpha")
440        createItem("Bravo")
441        createItem("Charlie")
442        navigateToItem(at: 0)
443        app.typeKey(.downArrow, modifierFlags: .shift)
444        app.typeKey(.downArrow, modifierFlags: .shift)
445        // Cmd+Click Bravo to create discontinuous selection {Alpha, Charlie}
446        cmdClickRow(withText: "Bravo")
447        // Cmd+A should select all, clearing any discontinuous state
448        app.typeKey("a", modifierFlags: .command)
449        app.typeKey(.delete, modifierFlags: [])
450
451        XCTAssertTrue(
452            emptyStateLabel.waitForExistence(timeout: 2),
453            "All items should be deleted after Select All"
454        )
455    }
456}