Closes the gap found at m10: enterUnwritableLock existed with zero call
sites. WritabilityProbe classifies the cause volume-first - a board on a
read-only DMG is also permission-denied by access(2), and "you don't
have permission" would send the user to a Get Info panel that cannot
help - with a pure classify(volumeIsReadOnly:isWritable:) truth table
and a two-syscall probe that rebuilds its URL to defeat NSURL resource
caching. ReadOnlyLockReason.unwritableLocation now carries the cause;
BannerCenter phrases the two ("this board's volume is read-only" vs
"you don't have permission to change this folder").
The probe wires once in BoardStoreRegistry.acquire, immediately after
the store loads - every open path funnels through it, and running
before the loose-file relocation and agent-guide hooks makes the
skipped-with-log guide write true by construction (its isWritableFile
pre-check demotes to second line of defense). The board still opens:
lock, not refusal.
The reconciling re-probe is now symmetric per 02's settled text - a
volume gone read-only mid-session raises the lock at the next probe
(sibling locks settle first, so a root returning read-only lands the
honest lock); the stale "deliberately one-way" comment and its pinning
test are gone. Save as Template's carve-out predicate extracted to a
testable allowsSave (behavior unchanged); Duplicate stays disabled.
11 tests added. 1649 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
816 lines
33 KiB
Swift
816 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 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()
|
|
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 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")
|
|
}
|
|
}
|