Realign code with the 2026-07-29 accessibility rulings
Three ruled behavior changes (DESIGN/10, resolution session 2026-07-29): - The board-change digest covers the trash while View > Show Trash is on: BoardDiff.between gains includingTrash, keying its card index by ItemPath so foreign purges, restores, and Empty Trash join the digest; crossings of the trash boundary still read deleted/restored, never moved, on both sides of the toggle. BoardStore.land passes the store's own isTrashVisible - no new injection seam. - A vanished head with surviving co-selection is still named: naming and recovery are independent axes, so BoardAnnouncer's vanished-focus rung fires on all branches while the survivors-veto now gates only the recovery half (recovery implies vanished, no longer both-or-neither). - Banner-row buttons are literal FKA Tab stops: BannerRow.controls is the row's testable button inventory, BannerRowView renders from it with .focusable() on each button; the combined VoiceOver element stays unconditional - custom actions and Tab stops are independent surfaces. 19 tests added, 2 expectations updated to the rulings. 1607 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -205,9 +205,11 @@ struct BoardAnnouncerFocusTests {
|
||||
}
|
||||
|
||||
/// 02-architecture.md's re-resolution rule stands where it always did: a selection with
|
||||
/// survivors is still the user's selection, and a reload may not re-aim it.
|
||||
@Test("Surviving selection members veto the recovery")
|
||||
func survivorsVetoRecovery() throws {
|
||||
/// 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()
|
||||
@@ -220,6 +222,47 @@ struct BoardAnnouncerFocusTests {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -526,6 +569,61 @@ struct BoardAnnouncerStoreTests {
|
||||
#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")
|
||||
|
||||
Reference in New Issue
Block a user