listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

ListlessMacUITests.swift (16143B)


      1 import CoreGraphics
      2 import XCTest
      3 
      4 final 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 }