Files
lanework/KanbanTests/BoardAnnouncerTests.swift
T
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

817 lines
33 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: Provenance
@Test("A reload carrying foreign changes speaks its digest — the design's own example sentence")
func foreignReloadSpeaks() {
var facts = BoardAnnouncer.ReloadFacts()
facts.diff = loudDiff()
#expect(BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added")
}
/// **The 2026-07-29 ruling, made structural.** Rungs 4 and 5 used to be gated on
/// `origin == .foreign`; the gate is gone and so is the field it read, so nothing about the
/// *kind* of reload can reach this type. Silence is the `EchoLedger`'s doing upstream — an echo
/// it vouched for end to end arrives here with an empty diff — and a reconciling sweep over a
/// blind window arrives with a full one, because receipt-less files classify foreign.
/// `BoardAnnouncerStoreTests` is where both halves are exercised against a real ledger.
@Test("Silence comes from the ledger's narrowing, never from the reload's kind")
func silenceIsTheLedgersDoing() {
var facts = BoardAnnouncer.ReloadFacts()
#expect(BoardAnnouncer.speech(for: facts) == nil, "an echo the ledger vouched for arrives empty")
facts.diff = loudDiff()
#expect(
BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added",
"the very same reload, with something foreign left in it, speaks"
)
}
// MARK: Specific beats generic
@Test("The vanishing-focus sentence displaces the digest — one reload, one sentence")
func vanishingFocusBeatsTheDigest() {
var facts = BoardAnnouncer.ReloadFacts()
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()
facts.endsBracketedOperation = true
facts.completion = "Pulled 3 commits"
facts.diff = loudDiff()
#expect(BoardAnnouncer.speech(for: facts) == "Pulled 3 commits")
}
/// Every free-tier 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()
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()
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()
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()
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()
facts.lockBefore = .unwritableLocation(.permissionDenied)
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()
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()
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()
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()
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. **What buys that silence is the receipt, not the
/// origin** (ruled 2026-07-29): the delete goes through the store, so the card's folder carries
/// a move pair the reload matches, and the vanishing classifies app-mediated.
@Test("The app's own delete keeps the successor its command chose — the reload never re-aims 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)
store.delete([card1ID])
let afterTheGesture = store.transient.selection.ids
#expect(afterTheGesture == [card2ID], "⌫ takes the successor sibling")
await reload(store, .appMediated)
#expect(store.transient.selection.ids == afterTheGesture)
#expect(store.transient.selection.ids != [lane1ID], "the lane recovery is the foreign case's move, not this one's")
}
/// The same gesture made by somebody else: no receipt, so the vanishing is foreign, the sentence
/// is spoken and focus recovers — the pair that shows the ledger is what tells them apart.
@Test("The identical delete made externally does recover, and says so")
func foreignDeleteRecoversAndSpeaks() 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 fixture.move("\(lane1)/\(card1)", toTrash: card1)
await reload(store, .appMediated)
#expect(log.lines == ["Card 'Fix login' was deleted externally"])
#expect(store.transient.selection.ids == [lane1ID])
}
@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"])
}
/// **"App-mediated echoes never announce", now grounded in the ledger rather than in the
/// reload's label**: the write goes through `performWrite`, the receipt matches the bytes the
/// walk read back, and the lane's edit is narrowed out of the digest before the announcer sees
/// it. The width assertion is there so the silence is never silence about a no-op.
@Test("An app-mediated echo says nothing at all — every file it touched carries a matching receipt")
func appMediatedEchoIsSilent() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = listen(to: store)
store.setLaneWidth(lane1ID, units: 3)
await reload(store, .appMediated)
#expect(store.snapshot.lanes.first?.width.value == 3, "the write really landed")
#expect(log.lines.isEmpty)
}
/// **The ruling itself** (10-accessibility.md ▸ Live board announcements, 2026-07-29): "a
/// reconciling reload that reveals external changes is never silent". Files changed during a
/// blind window carry no receipts, so they classify foreign and announce — the launch-catch-up
/// doctrine applied to speech. The old behaviour this replaces was a blanket
/// `origin == .foreign` gate that swallowed exactly this case.
@Test("A reconciling sweep announces what the blind window hid")
func reconcilingSweepAnnouncesItsFindings() 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: "Edited while we slept")
await reload(store, .reconciling)
#expect(log.lines == ["Board changed: 1 card edited"])
}
/// The other half of the same ruling: a reconciling sweep that finds the tree exactly as the app
/// left it is still quiet. Reconciliation is not itself news — the *findings* are.
@Test("A reconciling sweep over the app's own work is still silent")
func reconcilingSweepOverAnEchoIsSilent() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = listen(to: store)
store.setLaneWidth(lane1ID, units: 3)
await reload(store, .reconciling)
#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 free-tier 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 free-tier 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 written by a newer Lanework fails the whole load (01-storage-format.md
// § Malformed input) — the fail-fast that survived the 2026-07-31 optional-key ruling.
try fixture.item(lane1, "---\nschema: 99\norder: 1024\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: 99\norder: 1024\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")
}
}