import Foundation import Testing @testable import Kanban /// The live board's speech, as decisions — 10-accessibility.md ▸ Live board announcements: /// /// > Foreign changes announce, app-mediated echoes never do … one polite (non-interrupting) digest /// > per reload debounce … A vanishing focus is called out specifically … when the lane itself /// > vanished, recovery walks up then sideways … Bracketed operations announce once, at completion — /// > never their internal churn. The live-reload-resilience banner is an accessibility element and /// > is announced when it appears and when it clears. /// /// Every one of those is a rule about *what to say and where to put focus*, and `BoardAnnouncer` /// answers both without a window or a screen reader. The `NSAccessibility.post` on the other side of /// it is two lines with nothing to decide (`AccessibilityAnnouncer`) and is deliberately not tested. // MARK: - Fixtures private let lane1 = Ident.lane1 private let lane2 = Ident.lane2 private let lane3 = Ident.lane3 private let card1 = Ident.card1 private let card2 = Ident.card2 private let card3 = Ident.card3 private let lane1ID = ItemID(rawValue: lane1) private let lane2ID = ItemID(rawValue: lane2) private let lane3ID = ItemID(rawValue: lane3) private let card1ID = ItemID(rawValue: card1) private let card2ID = ItemID(rawValue: card2) private let card3ID = ItemID(rawValue: card3) /// Three lanes left to right — Todo (2 cards), Doing (1 card), Done (empty). private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.board() try fixture.lane(lane1, order: "1024", title: "Todo") try fixture.lane(lane2, order: "2048", title: "Doing") try fixture.lane(lane3, order: "3072", title: "Done") try fixture.card(card1, in: lane1, order: "1024", title: "Fix login") try fixture.card(card2, in: lane1, order: "2048", title: "Second") try fixture.card(card3, in: lane2, order: "1024", title: "Third") return fixture } private func selection(_ ids: Set, in container: ItemContainer = .board) -> ItemReferenceSet { ItemReferenceSet(ids: ids, container: container) } // MARK: - Focus @Suite("BoardAnnouncer — a vanishing focus") struct BoardAnnouncerFocusTests { @Test("A focus that survived the reload says nothing and moves nothing") func survivingFocus() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() try fixture.card(card1, in: lane1, order: "1024", title: "Fix login again") let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([card1ID]), focused: card1ID ) #expect(outcome == .survived) } @Test("A vanished card names itself and recovers to its lane") func vanishedCard() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)")) let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([card1ID]), focused: card1ID ) #expect(outcome.vanished == .card(title: "Fix login")) #expect(outcome.recovery == .lane(lane1ID)) } /// "The announcement then names the *lane*, not the card" — the implied-events discipline /// applied to speech, and the reason `VanishedFocus` has two cases rather than a card case with /// a lane attached. @Test("A card that went with its lane names the lane, and its count") func vanishedLaneStealsTheSubject() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() try FileManager.default.removeItem(at: fixture.url(lane1)) let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([card1ID]), focused: card1ID ) #expect(outcome.vanished == .lane(title: "Todo", cards: 2)) #expect(outcome.recovery == .lane(lane2ID), "up, then sideways: the next lane by order") } @Test("A focused lane that vanished is the lane case outright") func focusedLaneVanished() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() try FileManager.default.removeItem(at: fixture.url(lane2)) let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([lane2ID]), focused: lane2ID ) #expect(outcome.vanished == .lane(title: "Doing", cards: 1)) #expect(outcome.recovery == .lane(lane3ID)) } @Test("An emptied lane vanishing still names the lane, with no count clause to add") func vanishedEmptyLane() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() try FileManager.default.removeItem(at: fixture.url(lane3)) let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([lane3ID]), focused: lane3ID ) #expect(outcome.vanished == .lane(title: "Done", cards: 0)) } /// The sideways half of the walk: there is no next lane, so the previous one takes the position. @Test("The last lane vanishing recovers to the previous one") func lastLaneRecoversBackwards() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() try FileManager.default.removeItem(at: fixture.url(lane3)) let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([lane3ID]), focused: lane3ID ) #expect(outcome.recovery == .lane(lane2ID)) } /// "The board container only when no lanes remain." @Test("A board emptied of lanes recovers to the board container") func emptiedBoardRecoversToTheContainer() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() for lane in [lane1, lane2, lane3] { try FileManager.default.removeItem(at: fixture.url(lane)) } let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([card1ID]), focused: card1ID ) #expect(outcome.vanished == .lane(title: "Todo", cards: 2)) #expect(outcome.recovery == .boardContainer) } /// A checkout-shaped reload: every old lane is gone and different ones are there. The container /// is reserved for "no lanes remain", so focus lands on the leftmost lane that *is* there. @Test("Lanes replaced wholesale recover to the leftmost surviving lane, not to the container") func replacedLanesRecoverToTheFirstLane() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() for lane in [lane1, lane2, lane3] { try FileManager.default.removeItem(at: fixture.url(lane)) } try fixture.lane(Ident.lane4, order: "1024", title: "Inbox") let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([card1ID]), focused: card1ID ) #expect(outcome.recovery == .lane(ItemID(rawValue: Ident.lane4))) } /// 02-architecture.md's re-resolution rule stands where it always did: a selection with /// survivors is still the user's selection, and a reload may not re-aim it. @Test("Surviving selection members veto the recovery") func survivorsVetoRecovery() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)")) let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([card1ID, card2ID]), focused: card1ID ) #expect(outcome == .survived) } @Test("A trash selection never announces and never recovers — focus stays out of the trash") func trashSelectionIsOutOfScope() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.trashCard(card1, order: "1024", title: "Old") let before = try fixture.snapshot() try FileManager.default.removeItem(at: fixture.url(".trash/\(card1)")) let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([card1ID], in: .trash), focused: card1ID ) #expect(outcome == .survived) } @Test("No cursor, no sentence — a multi-item selection with no head falls through to the digest") func noFocusedItem() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.snapshot() try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)")) let outcome = BoardAnnouncer.focusOutcome( old: before, new: try fixture.snapshot(), selection: selection([card1ID]), focused: nil ) #expect(outcome == .survived) } } // MARK: - The ladder @Suite("BoardAnnouncer — one sentence per reload") struct BoardAnnouncerSpeechTests { /// A digest with something in it, so "silent" in the tests below is never silent by accident. private func loudDiff() -> BoardDiff { var diff = BoardDiff() diff.boardChanged = true diff.cards.edited = [card1ID, card2ID] diff.cards.added = [card3ID] return diff } private func breakage() -> BoardLoadError { BoardLoadError(path: "Todo/index.md", reason: .missingOrder) } // MARK: Origins @Test("A foreign reload speaks its digest — the design's own example sentence") func foreignReloadSpeaks() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.diff = loudDiff() #expect(BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added") } @Test("App-mediated echoes never announce — the user's own action is not news") func appMediatedIsSilent() { var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated) facts.diff = loudDiff() #expect(BoardAnnouncer.speech(for: facts) == nil) } @Test("A reconciling sweep is silent — it claims nothing changed") func reconcilingIsSilent() { var facts = BoardAnnouncer.ReloadFacts(origin: .reconciling) facts.diff = loudDiff() #expect(BoardAnnouncer.speech(for: facts) == nil) } @Test("A foreign reload that changed nothing says nothing") func quietForeignReload() { let facts = BoardAnnouncer.ReloadFacts(origin: .foreign) #expect(BoardAnnouncer.speech(for: facts) == nil) } // MARK: Specific beats generic @Test("The vanishing-focus sentence displaces the digest — one reload, one sentence") func vanishingFocusBeatsTheDigest() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.diff = loudDiff() facts.vanishedFocus = .card(title: "Fix login") #expect(BoardAnnouncer.speech(for: facts) == "Card 'Fix login' was deleted externally") } // MARK: Brackets @Test("A bracketed operation announces its completion, never its churn") func bracketAnnouncesCompletion() { var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated) facts.endsBracketedOperation = true facts.completion = "Pulled 3 commits" facts.diff = loudDiff() #expect(BoardAnnouncer.speech(for: facts) == "Pulled 3 commits") } /// Every base-edition bracket today. The seam exists; pro-m1 supplies the phrases. @Test("A bracket with no phrase to say stays silent rather than falling back to the digest") func bracketWithoutAPhrase() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.endsBracketedOperation = true facts.diff = loudDiff() facts.vanishedFocus = .card(title: "Fix login") #expect(BoardAnnouncer.speech(for: facts) == nil) } // MARK: Standing conditions @Test("A raised read-only lock outranks everything else the reload could say") func raisedLockWins() { var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated) facts.endsBracketedOperation = true facts.completion = "Pulled 3 commits" facts.lockAfter = .bracketedReloadFailed #expect( BoardAnnouncer.speech(for: facts) == "Error: This board couldn't be re-read after the last operation — showing the last good view, read-only" ) } @Test("The banner's announcement is the banner's own label — one condition, one sentence") func announcementMatchesTheRowLabel() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.lockAfter = .vanishedRoot #expect( BoardAnnouncer.speech(for: facts) == AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: .vanishedRoot)) ) } @Test("A lock that was already standing is not repeated on every reload") func standingLockIsNotRepeated() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.lockBefore = .vanishedRoot facts.lockAfter = .vanishedRoot facts.diff = { var diff = BoardDiff(); diff.boardChanged = true; return diff }() #expect(BoardAnnouncer.speech(for: facts) == "Board changed") } @Test("A lock whose cause changed is news again") func changedLockCauseSpeaksAgain() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.lockBefore = .unwritableLocation facts.lockAfter = .vanishedRoot #expect( BoardAnnouncer.speech(for: facts) == "Error: This board's folder is gone — showing the last good view, read-only" ) } @Test("Reload breakage announces on arrival") func raisedBreakage() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.breakageAfter = breakage() #expect( BoardAnnouncer.speech(for: facts) == AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage())) ) } @Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears") func clearedLock() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.lockBefore = .bracketedReloadFailed facts.diff = loudDiff() #expect(BoardAnnouncer.speech(for: facts) == "The board is editable again") } @Test("Cleared breakage is announced, below the lock when both heal at once") func clearedBreakage() { var facts = BoardAnnouncer.ReloadFacts(origin: .foreign) facts.breakageBefore = breakage() #expect(BoardAnnouncer.speech(for: facts) == "The board is loading again") facts.lockBefore = .bracketedReloadFailed #expect(BoardAnnouncer.speech(for: facts) == "The board is editable again") } /// A completion phrase already implies the board is reading and writing again, so it takes the /// one sentence the reload has. @Test("A completion phrase outranks the clearance it implies") func completionOutranksClearance() { var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated) facts.endsBracketedOperation = true facts.completion = "Switched to branch 'redesign'" facts.lockBefore = .bracketedReloadFailed #expect(BoardAnnouncer.speech(for: facts) == "Switched to branch 'redesign'") } } // MARK: - At the reload seam /// The half that is not a value function: **focus actually moves**. Driven through a real /// `BoardStore` over a real temp board, like `TransientBoardStateTests`, because the recovery's /// contract includes running *after* `TransientBoardState.resolve(against:)` on the reload path — /// a suite that called the pure function directly could pass with that wire cut. @MainActor @Suite("BoardAnnouncer — at the reload seam") struct BoardAnnouncerStoreTests { /// What the board said, read off `BoardStore.announce` — a class rather than a captured local so /// the escaping outlet and the assertions look at one value. @MainActor private final class SpokenLog { var lines: [String] = [] /// `nil` is the store deciding to say nothing, and is recorded as nothing — the outlet's own /// tolerance, so a test that expects silence expects an empty log rather than `[nil]`. func record(_ phrase: String?) { if let phrase { lines.append(phrase) } } } private func listen(to store: BoardStore) -> SpokenLog { let log = SpokenLog() store.announce = { log.record($0) } return log } private func reload(_ store: BoardStore, _ origin: WatchOrigin = .foreign) async { store.handleWatcherEvent(.treeChanged(origin)) await store.awaitQuiescence() } @Test("A foreign delete of the selected card leaves focus on its lane") func recoversToTheLane() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.select([card1ID], in: .board) try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)")) await reload(store) #expect(store.transient.selection.ids == [lane1ID]) #expect(store.transient.lastActiveLaneID == lane1ID, "⌘N after the surprise files where the user was left") } @Test("A foreign delete of the selected card's lane walks up, then sideways") func recoversSideways() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.select([card1ID], in: .board) try FileManager.default.removeItem(at: fixture.url(lane1)) await reload(store) #expect(store.transient.selection.ids == [lane2ID]) } /// The app's own delete already chose its successor (04-interactions.md ▸ The map); a recovery /// firing on the echo would override it. 02-architecture.md's silent-vanish rule is what an /// app-mediated reload still gets. @Test("An app-mediated echo leaves the emptied selection exactly as the set rule left it") func appMediatedDoesNotRecover() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.select([card1ID], in: .board) try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)")) await reload(store, .appMediated) #expect(store.transient.selection.isEmpty) } @Test("A reload that leaves the selection standing never re-aims it") func survivorsAreLeftAlone() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.select([card1ID, card2ID], in: .board) try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)")) await reload(store) #expect(store.transient.selection.ids == [card2ID]) } // MARK: What actually gets said @Test("A foreign reload speaks its digest exactly once") func foreignReloadSpeaksOnce() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) try fixture.card(card1, in: lane1, order: "1024", title: "Renamed") try fixture.card(card2, in: lane1, order: "2048", title: "Also renamed") await reload(store) #expect(log.lines == ["Board changed: 2 cards edited"]) } @Test("An app-mediated echo says nothing at all") func appMediatedEchoIsSilent() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) try fixture.card(card1, in: lane1, order: "1024", title: "Renamed") await reload(store, .appMediated) #expect(log.lines.isEmpty) } @Test("A reload that changed nothing says nothing") func quietReloadIsSilent() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) await reload(store) #expect(log.lines.isEmpty) } @Test("The vanishing-focus sentence is the one the reload spends its sentence on") func vanishingFocusIsWhatIsHeard() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.select([card1ID], in: .board) let log = listen(to: store) try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)")) await reload(store) #expect(log.lines == ["Card 'Fix login' was deleted externally"]) } /// The seam pro-m1 fills. No base-edition operation passes a phrase today, so this is the one /// place the completion path is exercised end to end — including that the phrase is *consumed* /// rather than left armed for whatever reload comes next. @Test("A bracketed operation announces at completion, once, and never again") func bracketSpeaksOnceAtCompletion() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) try store.performWholesale(announcing: "Pulled 3 commits") { try fixture.card(card1, in: lane1, order: "1024", title: "From the remote") } await reload(store, .appMediated) #expect(log.lines == ["Pulled 3 commits"], "the operation's result, not the churn inside it") log.lines.removeAll() try fixture.card(card2, in: lane1, order: "2048", title: "An agent's edit") await reload(store) #expect(log.lines == ["Board changed: 1 card edited"], "the phrase was consumed, not carried forward") } @Test("A bracket with no phrase to say stays quiet — every base-edition bracket today") func silentBracket() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) try store.performWholesale { try fixture.card(card1, in: lane1, order: "1024", title: "Rewritten") } await reload(store, .appMediated) #expect(log.lines.isEmpty) } /// "Including the read-only lock after a failed bracketed reload" — the banner appears, and the /// announcement is the row's own sentence. @Test("A failed bracketed reload announces the lock instead of the operation") func failedBracketAnnouncesTheLock() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) try store.performWholesale(announcing: "Pulled 3 commits") { // A lane with no `order` fails the whole load (01-storage-format.md § Malformed input). try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n") } await reload(store, .appMediated) #expect(store.readOnlyLock == .bracketedReloadFailed) #expect( log.lines == [ "Error: This board couldn't be re-read after the last operation — showing the last good view, read-only" ] ) } @Test("The lock's clearance is announced too") func clearedLockIsAnnounced() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) try store.performWholesale { try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n") } await reload(store, .appMediated) #expect(store.readOnlyLock == .bracketedReloadFailed) let log = listen(to: store) try fixture.lane(lane1, order: "1024", title: "Todo") await reload(store) #expect(store.readOnlyLock == nil) #expect(log.lines == ["The board is editable again"], "the healing outranks the digest of what healed it") } }