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() -> BoardLoadFailure {
        BoardLoadFailure(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 bracket today. The seam exists; nothing in the app supplies a phrase yet.
    @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 a future wholesale operation fills. Nothing in the app 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 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")
    }
}
