Files
lanework/KanbanTests/BoardAnnouncerTests.swift
T
rzen 28ca2c3f50 Realign code with the 2026-07-29 accessibility rulings
Three ruled behavior changes (DESIGN/10, resolution session 2026-07-29):

- The board-change digest covers the trash while View > Show Trash is on:
  BoardDiff.between gains includingTrash, keying its card index by
  ItemPath so foreign purges, restores, and Empty Trash join the digest;
  crossings of the trash boundary still read deleted/restored, never
  moved, on both sides of the toggle. BoardStore.land passes the store's
  own isTrashVisible - no new injection seam.

- A vanished head with surviving co-selection is still named: naming and
  recovery are independent axes, so BoardAnnouncer's vanished-focus rung
  fires on all branches while the survivors-veto now gates only the
  recovery half (recovery implies vanished, no longer both-or-neither).

- Banner-row buttons are literal FKA Tab stops: BannerRow.controls is
  the row's testable button inventory, BannerRowView renders from it
  with .focusable() on each button; the combined VoiceOver element stays
  unconditional - custom actions and Tab stops are independent surfaces.

19 tests added, 2 expectations updated to the rulings. 1607 green on
both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 11:37:46 -04:00

762 lines
30 KiB
Swift

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<ItemID>, 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. **The sentence is
/// not the move's passenger** (ruled 2026-07-29): the head still vanished, and that is what the
/// rule exists to say.
@Test("Surviving selection members veto the recovery — and only the recovery")
func survivorsVetoRecoveryButNotTheSentence() 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.vanished == .card(title: "Fix login"), "the thing under the cursor was deleted")
#expect(outcome.recovery == nil, "the survivors hold the selection where the user put it")
}
/// The lane rung of the same ruling: naming and recovery are independent whichever subject the
/// composition picked.
@Test("A vanished head's lane is still named when co-selected cards elsewhere survive")
func survivorsVetoTheLaneRecoveryButNotItsSentence() 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, card3ID]),
focused: card1ID
)
#expect(outcome.vanished == .lane(title: "Todo", cards: 2))
#expect(outcome.recovery == nil, "card 3 survived in Doing — the reload may not walk the selection away")
}
/// The complement, and the reason the head is the subject rather than the set: a *co-selected*
/// card vanishing under a surviving cursor is nothing to announce — it falls to the digest's
/// counts, unnamed.
@Test("A vanished non-head member says nothing — the cursor is what the sentence is about")
func vanishedNonHeadIsTheDigestsBusiness() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.snapshot()
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card2)"))
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])
}
/// Both halves of the 2026-07-29 ruling at the seam that has to honour them together: the
/// sentence names the head, and the selection is left exactly where the user's surviving
/// members hold it.
@Test("A vanished head with a surviving co-selection is named but never moved")
func vanishedHeadWithSurvivorsIsNamed() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1ID, card2ID], in: .board, head: card1ID)
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"])
#expect(store.transient.selection.ids == [card2ID], "no focus move — the survivor still holds it")
#expect(store.transient.selectionHead == nil, "the head re-anchors on the next arrow, per 02")
}
// MARK: The shown trash
/// **The digest covers the trash only while the trash lane is shown** (ruled 2026-07-29), at the
/// seam that reads the visibility: the store asks its own transient state, so nothing about the
/// summarizer knows what a window is.
@Test("A purge under a shown trash joins the digest")
func shownTrashPurgeIsAnnounced() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(Ident.card4, order: "1024", title: "Old")
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
let log = listen(to: store)
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card4)"))
await reload(store)
#expect(log.lines == ["Board changed: 1 card deleted"])
}
@Test("The same purge under a hidden trash is silent")
func hiddenTrashPurgeIsSilent() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(Ident.card4, order: "1024", title: "Old")
let store = try BoardStore(rootURL: fixture.root)
let log = listen(to: store)
#expect(!store.transient.isTrashVisible, "hidden on every open — visiting the trash is an errand")
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card4)"))
await reload(store)
#expect(log.lines.isEmpty)
}
// 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")
}
}