import XCTest /// **The golden paths, driven through the shipping app** — create, rename, move, cut and paste, /// delete, restore, empty the trash, undo and redo. /// /// ### What this suite is for, and what it is not /// /// Every rule these flows exercise is already pinned somewhere in `KanbanTests`: the write paths by /// `*WriteTests`, the grammar by `SelectionGrammarTests` and `KeyboardGrammarTests`, the trash by /// `TrashModelTests` and `TrashWriteTests`, the stack by `UndoWriteTests`. Not one assertion below is /// a fact those suites cannot state faster and more precisely. /// /// What they cannot state is that the pieces are *wired together* — that File ▸ New Card reaches /// `beginNewCard`, that the editor it opens is focused, that Return commits it, that the write lands, /// that the watcher rounds it back, and that the lane the user is looking at now says "4 cards". This /// suite is that sentence, once per flow, and nothing else. When one of these fails and the unit /// suites stay green, the defect is in the wiring — a menu item scoped to the wrong focus value, a /// command that lost its shortcut, a reload that never arrived. /// /// ### How a flow asserts /// /// **On lane labels**, almost always (`XCUIApplication.awaitLane`). A lane's accessibility label is /// "⟨title⟩, lane, N cards" and the count is the *rendered* one, so one label assertion covers the /// visible badge, the spoken count and the layout at once — 10-accessibility.md ▸ Trash lane makes /// them one number, and this is where that pays. A card's own element carries only its title, which /// does not change when the card changes lanes, so asserting on the card would assert nothing about /// where it went. /// /// The fixture is the `standard` board: three lanes — **To Do** (3 cards), **Doing** (1), **Done** /// (1) — plus one card already in `.trash/`. Every flow below starts from exactly that and states /// its own arithmetic against it. /// /// ### Running these /// /// They drive the real app through the real menu bar, so the machine running them must have granted /// the test runner Accessibility control (System Settings ▸ Privacy & Security ▸ Accessibility) and /// must not be locked or headless — the whole of `KanbanUITests/EndToEndVerification.md`'s /// prerequisites section, which is where the run command lives too. /// /// ### Reading a failure /// /// A failure on a `clickMenuItem` is a *validation* failure: the row was missing or disabled, which /// means a focused value did not reach it. A failure on an `awaitLane` is the flow itself: the /// command ran and the board did not end up where the design says it should. A failure on a /// `waitForExistence` for a card or an editor is navigation, in this file. final class EndToEndFlowTests: XCTestCase { override func setUp() { super.setUp() // A flow is a sequence: once a step has failed, every later assertion is about a board in a // state nobody wrote down. continueAfterFailure = false } // MARK: - Creating /// **File ▸ New Card (⌘N)** — the placeholder opens focused in the target lane, Return commits, /// and the card is on the board (04-interactions.md ▸ Grammar: "Return commits and re-selects the /// lane"). /// /// The target is made explicit by selecting a card in "To Do" first. ⌘N with nothing selected /// would also land there (`NewCardTarget.resolve` falls back to the first lane), but a flow that /// relies on a fallback is a flow that would pass for the wrong reason. @MainActor func testCreateCard() throws { let app = XCUIApplication.launchedWithFixtureBoard() app.selectCard(StandardBoard.firstLaneCards[0]) app.clickMenuItem("New Card", in: "File") app.typeIntoInlineEditor("A card the suite made", prompt: Phrase.cardTitlePrompt) XCTAssertTrue( app.element(labeled: "A card the suite made").waitForExistence(timeout: XCUIApplication.uiTimeout), "the new card never appeared on the board" ) app.awaitLane(StandardBoard.toDo, cards: 4, "the new card should have landed in To Do") } /// **File ▸ New Lane (⇧⌘N)** — a lane with no title and no editor (`BoardStore.createLane`), so /// it arrives reading the untitled placeholder with an empty count. /// /// The count is the assertion that matters: a new lane that arrived holding cards would mean the /// create had landed somewhere it should not have. @MainActor func testCreateLane() throws { let app = XCUIApplication.launchedWithFixtureBoard() app.clickMenuItem("New Lane", in: "File") app.awaitLane(Phrase.untitled, cards: 0, "File ▸ New Lane should add one empty, untitled lane") // The three lanes that were already there are untouched — a create is not a re-divide of the // board's contents. app.awaitLane(StandardBoard.toDo, cards: 3) app.awaitLane(StandardBoard.doing, cards: 1) app.awaitLane(StandardBoard.done, cards: 1) } // MARK: - Renaming /// **Board ▸ Rename** on a card — the inline editor, seeded with the current title, committed /// with Return (04-interactions.md ▸ Selection; the menu row exists "for completeness and /// remapping", the pointer path being a slow double click). /// /// Select All then type is how the seeded title is replaced: inside a focused field that chord /// belongs to the field editor, because the board's own Select All disables while an inline /// editor is open. @MainActor func testRenameCardInline() throws { let app = XCUIApplication.launchedWithFixtureBoard() let original = StandardBoard.firstLaneCards[3] let renamed = "Ship the audit, renamed" app.selectCard(original) app.clickMenuItem("Rename", in: "Board") app.typeIntoInlineEditor(renamed, prompt: Phrase.cardTitlePrompt, replacingExisting: true) XCTAssertTrue( app.element(labeled: renamed).waitForExistence(timeout: XCUIApplication.uiTimeout), "the renamed card never appeared" ) // A rename is not a move: the lane still holds the same three cards. app.awaitLane(StandardBoard.toDo, cards: 3) } // MARK: - Moving /// **A pointer drag across lanes** (04-interactions.md ▸ Drag and drop) — the card leaves "To Do" /// and lands in "Doing", which the two lanes' counts say and nothing else has to. /// /// This is the suite's one gesture that goes through a real AppKit dragging session rather than /// through a menu, and it is correspondingly the most environment-sensitive /// (`XCUIApplication.dragCard` carries the details). It earns its place anyway: the drag is the /// board's headline interaction, its reflow is the largest piece of geometry in the app, and no /// unit test can press a mouse button. @MainActor func testDragCardBetweenLanes() throws { let app = XCUIApplication.launchedWithFixtureBoard() app.dragCard(StandardBoard.firstLaneCards[0], onto: StandardBoard.richCard) app.awaitLane(StandardBoard.doing, cards: 2, "the dragged card should have landed in Doing") app.awaitLane(StandardBoard.toDo, cards: 2, "the dragged card should have left To Do") } /// **Cut and paste across lanes (⌘X / ⌘V)** — the keyboard's own move (04-interactions.md /// ▸ Clipboard), driven through the Edit menu because the board answers `cut:`/`paste:` as a /// responder and the menu is where that wiring is visible. /// /// The paste lands the card **after** the anchor card, which is why the destination is selected /// by clicking a card in it rather than by clicking the lane. @MainActor func testCutAndPasteCardAcrossLanes() throws { let app = XCUIApplication.launchedWithFixtureBoard() app.selectCard(StandardBoard.firstLaneCards[0]) app.clickMenuItem("Cut", in: "Edit") app.selectCard(StandardBoard.richCard) app.clickMenuItem("Paste", in: "Edit") app.awaitLane(StandardBoard.doing, cards: 2, "the cut card should have landed in Doing") app.awaitLane(StandardBoard.toDo, cards: 2, "the cut card should have left To Do") } /// **⌘Z and ⇧⌘Z over a move** (13-native-undo.md ▸ Rules) — the chords rather than the menu rows, /// because those rows are titled dynamically ("Undo Move Card") by `NSUndoManager` and a test /// that spelled the title would be asserting the platform's composition rather than the app's /// stack. /// /// The move is the cut/paste above, so what undo has to put back is a folder that physically /// changed lanes — the interesting case, and the one the native provider registers an inverse /// for at the Writer boundary. @MainActor func testUndoAndRedoOfAMove() throws { let app = XCUIApplication.launchedWithFixtureBoard() app.selectCard(StandardBoard.firstLaneCards[0]) app.clickMenuItem("Cut", in: "Edit") app.selectCard(StandardBoard.richCard) app.clickMenuItem("Paste", in: "Edit") app.awaitLane(StandardBoard.doing, cards: 2) app.typeKey("z", modifierFlags: .command) app.awaitLane(StandardBoard.toDo, cards: 3, "⌘Z should have returned the card to To Do") app.awaitLane(StandardBoard.doing, cards: 1) app.typeKey("z", modifierFlags: [.shift, .command]) app.awaitLane(StandardBoard.doing, cards: 2, "⇧⌘Z should have moved the card back into Doing") app.awaitLane(StandardBoard.toDo, cards: 2) } // MARK: - The trash /// **Delete, then restore** — the whole of the current trash grammar's board side /// (03-board-ui.md § Trash, resettled 2026-07-28 for the materialized trash). /// /// Four claims in one flow, because they are one story: /// /// 1. **File ▸ Delete on a board card does not confirm.** The card moves into `/.trash/` /// and that is recoverable, so there is no alert — this flow would hang on one if there were. /// 2. **View ▸ Show Trash** puts the column on the board as the last container, holding what was /// already in it plus what just arrived. /// 3. **Restore is an ordinary move out**: ⌘X in the trash, ⌘V into a lane. There is no Put Back /// and there is nothing else to press. /// 4. The counts on both sides move, both times. @MainActor func testDeleteToTrashAndRestore() throws { let app = XCUIApplication.launchedWithFixtureBoard() let doomed = StandardBoard.firstLaneCards[0] app.selectCard(doomed) app.clickMenuItem("Delete", in: "File") app.awaitLane(StandardBoard.toDo, cards: 2, "the deleted card should have left the lane") app.clickMenuItem("Show Trash", in: "View") app.awaitTrash(cards: 2) // The restore: cut in the trash, paste into a live lane. app.selectCard(doomed) app.clickMenuItem("Cut", in: "Edit") app.selectCard(StandardBoard.doneCard) app.clickMenuItem("Paste", in: "Edit") app.awaitLane(StandardBoard.done, cards: 2, "the restored card should have landed in Done") app.awaitTrash(cards: 1) } /// **File ▸ Empty Trash… (⇧⌘⌫)** — "the alert always appears" (03-board-ui.md § Trash), naming /// the true count, and confirming it purges the container. /// /// The trash has to be *shown* for the row to be enabled at all ("hidden, it is invisible to /// every gesture"), which is the first click here and is itself part of the claim. @MainActor func testEmptyTrash() throws { let app = XCUIApplication.launchedWithFixtureBoard() app.clickMenuItem("Show Trash", in: "View") app.awaitTrash(cards: 1) app.clickMenuItem("Empty Trash…", in: "File") // The prompt is `TrashModel.emptyTrashPrompt`: the count in the title, Delete and Cancel as // the two answers. It is asserted rather than merely dismissed, because "the alert always // appears" is the design rule under test. // // Queried app-wide rather than through `dialogs`/`sheets`, deliberately: which of those a // SwiftUI `.alert` surfaces as on macOS is the framework's choice and not a contract, while // the button titles and the sentence are `TrashConfirmations`' and `TrashModel`'s. let confirm = app.buttons["Delete"] XCTAssertTrue( confirm.waitForExistence(timeout: XCUIApplication.uiTimeout), "Empty Trash… did not raise its confirmation" ) XCTAssertTrue( app.element(labelContaining: "Permanently delete \(Phrase.cards(1))").exists, "the confirmation did not name the trash's count" ) XCTAssertTrue(app.buttons["Cancel"].exists, "the confirmation had no Cancel") confirm.click() app.awaitTrash(cards: 0) } // MARK: - Templates /// **File ▸ New Board… (⌥⌘N) as far as this suite can drive it** — the chooser opens, the bundled /// templates are listed and selectable, and Choose is live. /// /// ### Why it stops there /// /// Choose runs an `NSSavePanel`, and in a sandboxed app that panel is **Powerbox** — a separate, /// system-owned process (`com.apple.appkit.xpc.openAndSavePanelService`). Driving it means /// driving another application's UI from inside this one's test, on a surface Apple changes /// between releases and which has no accessibility contract of its own. A test that did it would /// fail for reasons that have nothing to do with this app, which is worse than not having the /// test: a flaky gate teaches people to re-run rather than to read. /// /// So the automated half ends at the panel, and **instantiating a template is a manual step** — /// written down in `KanbanUITests/EndToEndVerification.md` ▸ Manual-only flows rather than faked /// here. Everything behind the panel is unit-tested (`TemplateEngineTests` covers the copy, the /// `template:` key, the collision ladder and the cancellation). @MainActor func testTemplateChooserUpToTheSavePanel() throws { let app = XCUIApplication.launchedWithFixtureBoard() app.clickMenuItem("New Board…", in: "File") let chooser = app.windows["New Board"] XCTAssertTrue( chooser.waitForExistence(timeout: XCUIApplication.uiTimeout), "the template chooser did not appear" ) // "Basic" is the bundled tier's lowest `template.order`, so it is the row the chooser opens // on — and a tile is a button to the accessibility tree, labeled by its name // (10-accessibility.md ▸ Template chooser). let basic = app.element(labeled: "Basic") XCTAssertTrue(basic.waitForExistence(timeout: XCUIApplication.uiTimeout), "the Basic template is not listed") basic.click() let choose = chooser.buttons["Choose"] XCTAssertTrue(choose.exists, "the chooser has no Choose button") XCTAssertTrue(choose.isEnabled, "Choose is disabled on a loadable template") // Cancel rather than Choose: pressing Choose hands the flow to Powerbox (see above). The // chooser closing is the last thing this test can honestly assert. chooser.buttons["Cancel"].click() XCTAssertTrue( chooser.waitForNonExistence(timeout: XCUIApplication.uiTimeout), "Cancel did not dismiss the template chooser" ) } }