Implement live accessibility announcements
The board speaks when files change under the user, per DESIGN/10 § Live board announcements. BoardDiff is the pure snapshot summarizer (identity sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so pro-m1's semantic commit engine can build on it; edited = rendered content only, moved beats edited, implied events don't steal the subject). BoardAnnouncer is the decision seam: focusOutcome computes the vanishing-focus sentence and the walk-up-then-sideways recovery (next lane by order, else previous, board container only when none remain, never the trash); speech(for:) is the one-sentence precedence ladder — raised condition > bracket completion > cleared condition > vanished focus > digest — foreign-only for the last two rungs, so app-mediated echoes stay silent. BoardStore.land assembles ReloadFacts and posts exactly one sentence per reload through the injectable announce outlet (AccessibilityAnnouncer, medium priority, never interrupting). Selection recovery layers on top of ItemReferenceSet re-resolution — survivors veto, the emptied selection lands on the vanished item's lane and re-arms ⌘N's active-lane memory. performWholesale(announcing:) arms a completion phrase consumed by the closing reload — nil on every base bracket today; pro-m1 fills git phrasings. Locks raised outside the reload path (vanished root, unwritable location) announce through the same ladder, and the banner strip is a labeled "Board status" container whose row labels are the announced sentences (AccessibilityPhrases.bannerLabel — one string for eye and ear). Announcements classify at reload granularity (WatchOrigin) as a deliberate interim: DESIGN/02's EchoLedger (per-file classification, the announcer's specified input, git-free) was scheduled with the auto-committer that the edition split moved to pro-m1 — filed on the Redesign board for a ruling. 1533 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -134,4 +134,131 @@ struct AccessibilityPhrasesTests {
|
||||
#expect(AccessibilityPhrases.trashVisibility(shown: true) == "Trash shown")
|
||||
#expect(AccessibilityPhrases.trashVisibility(shown: false) == "Trash hidden")
|
||||
}
|
||||
|
||||
// MARK: - The live board digest
|
||||
|
||||
/// 10-accessibility.md's own example sentence, reproduced exactly — which is what fixes the
|
||||
/// category order's first pair (edited before added).
|
||||
@Test("The digest is the design's own sentence")
|
||||
func digestExample() {
|
||||
var diff = BoardDiff()
|
||||
diff.cards.edited = [id(1), id(2)]
|
||||
diff.cards.added = [id(3)]
|
||||
|
||||
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 2 cards edited, 1 card added")
|
||||
}
|
||||
|
||||
@Test("Zero categories are omitted, never spoken as 'and 0 cards moved'")
|
||||
func digestOmitsEmptyCategories() {
|
||||
var diff = BoardDiff()
|
||||
diff.cards.deleted = [id(1)]
|
||||
|
||||
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 1 card deleted")
|
||||
}
|
||||
|
||||
@Test("Every count folds its plural")
|
||||
func digestFoldsPlurals() {
|
||||
var diff = BoardDiff()
|
||||
diff.cards.moved = [id(1)]
|
||||
diff.lanes.added = [id(2), id(3)]
|
||||
|
||||
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 1 card moved, 2 lanes added")
|
||||
}
|
||||
|
||||
/// Cards before lanes, and within a kind: edited, added, moved, deleted.
|
||||
@Test("The category order is fixed, cards before lanes")
|
||||
func digestOrdering() {
|
||||
var diff = BoardDiff()
|
||||
diff.cards.edited = [id(1)]
|
||||
diff.cards.added = [id(2)]
|
||||
diff.cards.moved = [id(3)]
|
||||
diff.cards.deleted = [id(4)]
|
||||
diff.lanes.edited = [id(5)]
|
||||
diff.lanes.added = [id(6)]
|
||||
diff.lanes.moved = [id(7)]
|
||||
diff.lanes.deleted = [id(8)]
|
||||
|
||||
#expect(
|
||||
AccessibilityPhrases.boardChanged(diff)
|
||||
== "Board changed: 1 card edited, 1 card added, 1 card moved, 1 card deleted, "
|
||||
+ "1 lane edited, 1 lane added, 1 lane moved, 1 lane deleted"
|
||||
)
|
||||
}
|
||||
|
||||
/// "Silence about a mutating board is a lie" — a change no bucket counts still says something.
|
||||
@Test("An uncounted change is the bare sentence")
|
||||
func digestBareSentence() {
|
||||
var diff = BoardDiff()
|
||||
diff.boardChanged = true
|
||||
|
||||
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed")
|
||||
}
|
||||
|
||||
@Test("A board that did not change says nothing at all")
|
||||
func digestSilence() {
|
||||
#expect(AccessibilityPhrases.boardChanged(BoardDiff()) == nil)
|
||||
}
|
||||
|
||||
// MARK: - A vanishing focus
|
||||
|
||||
@Test("A vanished card is named — the design's own sentence")
|
||||
func vanishedCard() {
|
||||
#expect(
|
||||
AccessibilityPhrases.vanishedFocus(.card(title: "Fix login"))
|
||||
== "Card 'Fix login' was deleted externally"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A vanished lane names itself and what went with it")
|
||||
func vanishedLane() {
|
||||
#expect(
|
||||
AccessibilityPhrases.vanishedFocus(.lane(title: "Doing", cards: 5))
|
||||
== "Lane 'Doing' was deleted externally, with 5 cards"
|
||||
)
|
||||
#expect(
|
||||
AccessibilityPhrases.vanishedFocus(.lane(title: "Doing", cards: 1))
|
||||
== "Lane 'Doing' was deleted externally, with 1 card"
|
||||
)
|
||||
}
|
||||
|
||||
/// "With 0 cards" would be an odd way to say "and nothing else went with it".
|
||||
@Test("An empty lane vanishing has no count clause")
|
||||
func vanishedEmptyLane() {
|
||||
#expect(
|
||||
AccessibilityPhrases.vanishedFocus(.lane(title: "Done", cards: 0))
|
||||
== "Lane 'Done' was deleted externally"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("An untitled item still gets a name in the sentence")
|
||||
func vanishedUntitled() {
|
||||
#expect(
|
||||
AccessibilityPhrases.vanishedFocus(.card(title: nil))
|
||||
== "Card 'Untitled' was deleted externally"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The banner strip
|
||||
|
||||
/// The tone must reach a VoiceOver user as a *word*: colour cannot carry it, and the row's label
|
||||
/// and its announcement are the same string by construction.
|
||||
@Test("A banner row speaks its tone before its headline")
|
||||
func bannerLabel() {
|
||||
#expect(AccessibilityPhrases.bannerLabel(tone: .error, headline: "Couldn't move 'Fix login'")
|
||||
== "Error: Couldn't move 'Fix login'")
|
||||
#expect(AccessibilityPhrases.bannerTonePrefix(.warning) == "Warning")
|
||||
#expect(AccessibilityPhrases.bannerTonePrefix(.info) == "Status", "the platform's word for a calm state")
|
||||
}
|
||||
|
||||
@Test("A healed condition states the regained capability, not the vanished cause")
|
||||
func clearedConditions() {
|
||||
#expect(AccessibilityPhrases.readOnlyLockCleared == "The board is editable again")
|
||||
#expect(AccessibilityPhrases.reloadBreakageCleared == "The board is loading again")
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct identities for the digest cases, which care only about *counts* — the diff's own suite
|
||||
/// is where identity is at stake.
|
||||
private func id(_ n: Int) -> ItemID {
|
||||
ItemID(rawValue: "0000000\(n)-0000-4000-8000-000000000000")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
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.
|
||||
@Test("Surviving selection members veto the recovery")
|
||||
func survivorsVetoRecovery() 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 == .survived)
|
||||
}
|
||||
|
||||
@Test("A trash selection never announces and never recovers — focus stays out of the trash")
|
||||
func trashSelectionIsOutOfScope() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.trashCard(card1, order: "1024", title: "Old")
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(".trash/\(card1)"))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID], in: .trash),
|
||||
focused: card1ID
|
||||
)
|
||||
|
||||
#expect(outcome == .survived)
|
||||
}
|
||||
|
||||
@Test("No cursor, no sentence — a multi-item selection with no head falls through to the digest")
|
||||
func noFocusedItem() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID]),
|
||||
focused: nil
|
||||
)
|
||||
|
||||
#expect(outcome == .survived)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The ladder
|
||||
|
||||
@Suite("BoardAnnouncer — one sentence per reload")
|
||||
struct BoardAnnouncerSpeechTests {
|
||||
|
||||
/// A digest with something in it, so "silent" in the tests below is never silent by accident.
|
||||
private func loudDiff() -> BoardDiff {
|
||||
var diff = BoardDiff()
|
||||
diff.boardChanged = true
|
||||
diff.cards.edited = [card1ID, card2ID]
|
||||
diff.cards.added = [card3ID]
|
||||
return diff
|
||||
}
|
||||
|
||||
private func breakage() -> BoardLoadError {
|
||||
BoardLoadError(path: "Todo/index.md", reason: .missingOrder)
|
||||
}
|
||||
|
||||
// MARK: Origins
|
||||
|
||||
@Test("A foreign reload speaks its digest — the design's own example sentence")
|
||||
func foreignReloadSpeaks() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added")
|
||||
}
|
||||
|
||||
@Test("App-mediated echoes never announce — the user's own action is not news")
|
||||
func appMediatedIsSilent() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
@Test("A reconciling sweep is silent — it claims nothing changed")
|
||||
func reconcilingIsSilent() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .reconciling)
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
@Test("A foreign reload that changed nothing says nothing")
|
||||
func quietForeignReload() {
|
||||
let facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
// MARK: Specific beats generic
|
||||
|
||||
@Test("The vanishing-focus sentence displaces the digest — one reload, one sentence")
|
||||
func vanishingFocusBeatsTheDigest() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.diff = loudDiff()
|
||||
facts.vanishedFocus = .card(title: "Fix login")
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Card 'Fix login' was deleted externally")
|
||||
}
|
||||
|
||||
// MARK: Brackets
|
||||
|
||||
@Test("A bracketed operation announces its completion, never its churn")
|
||||
func bracketAnnouncesCompletion() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Pulled 3 commits"
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Pulled 3 commits")
|
||||
}
|
||||
|
||||
/// Every base-edition bracket today. The seam exists; pro-m1 supplies the phrases.
|
||||
@Test("A bracket with no phrase to say stays silent rather than falling back to the digest")
|
||||
func bracketWithoutAPhrase() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.endsBracketedOperation = true
|
||||
facts.diff = loudDiff()
|
||||
facts.vanishedFocus = .card(title: "Fix login")
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
// MARK: Standing conditions
|
||||
|
||||
@Test("A raised read-only lock outranks everything else the reload could say")
|
||||
func raisedLockWins() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Pulled 3 commits"
|
||||
facts.lockAfter = .bracketedReloadFailed
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== "Error: This board couldn't be re-read after the last operation — showing the last good view, read-only"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("The banner's announcement is the banner's own label — one condition, one sentence")
|
||||
func announcementMatchesTheRowLabel() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockAfter = .vanishedRoot
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: .vanishedRoot))
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A lock that was already standing is not repeated on every reload")
|
||||
func standingLockIsNotRepeated() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockBefore = .vanishedRoot
|
||||
facts.lockAfter = .vanishedRoot
|
||||
facts.diff = { var diff = BoardDiff(); diff.boardChanged = true; return diff }()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Board changed")
|
||||
}
|
||||
|
||||
@Test("A lock whose cause changed is news again")
|
||||
func changedLockCauseSpeaksAgain() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockBefore = .unwritableLocation
|
||||
facts.lockAfter = .vanishedRoot
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== "Error: This board's folder is gone — showing the last good view, read-only"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Reload breakage announces on arrival")
|
||||
func raisedBreakage() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.breakageAfter = breakage()
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage()))
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears")
|
||||
func clearedLock() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockBefore = .bracketedReloadFailed
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "The board is editable again")
|
||||
}
|
||||
|
||||
@Test("Cleared breakage is announced, below the lock when both heal at once")
|
||||
func clearedBreakage() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.breakageBefore = breakage()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "The board is loading again")
|
||||
|
||||
facts.lockBefore = .bracketedReloadFailed
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "The board is editable again")
|
||||
}
|
||||
|
||||
/// A completion phrase already implies the board is reading and writing again, so it takes the
|
||||
/// one sentence the reload has.
|
||||
@Test("A completion phrase outranks the clearance it implies")
|
||||
func completionOutranksClearance() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Switched to branch 'redesign'"
|
||||
facts.lockBefore = .bracketedReloadFailed
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Switched to branch 'redesign'")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - At the reload seam
|
||||
|
||||
/// The half that is not a value function: **focus actually moves**. Driven through a real
|
||||
/// `BoardStore` over a real temp board, like `TransientBoardStateTests`, because the recovery's
|
||||
/// contract includes running *after* `TransientBoardState.resolve(against:)` on the reload path —
|
||||
/// a suite that called the pure function directly could pass with that wire cut.
|
||||
@MainActor
|
||||
@Suite("BoardAnnouncer — at the reload seam")
|
||||
struct BoardAnnouncerStoreTests {
|
||||
|
||||
/// What the board said, read off `BoardStore.announce` — a class rather than a captured local so
|
||||
/// the escaping outlet and the assertions look at one value.
|
||||
@MainActor
|
||||
private final class SpokenLog {
|
||||
var lines: [String] = []
|
||||
|
||||
/// `nil` is the store deciding to say nothing, and is recorded as nothing — the outlet's own
|
||||
/// tolerance, so a test that expects silence expects an empty log rather than `[nil]`.
|
||||
func record(_ phrase: String?) {
|
||||
if let phrase { lines.append(phrase) }
|
||||
}
|
||||
}
|
||||
|
||||
private func listen(to store: BoardStore) -> SpokenLog {
|
||||
let log = SpokenLog()
|
||||
store.announce = { log.record($0) }
|
||||
return log
|
||||
}
|
||||
|
||||
private func reload(_ store: BoardStore, _ origin: WatchOrigin = .foreign) async {
|
||||
store.handleWatcherEvent(.treeChanged(origin))
|
||||
await store.awaitQuiescence()
|
||||
}
|
||||
|
||||
@Test("A foreign delete of the selected card leaves focus on its lane")
|
||||
func recoversToTheLane() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.selection.ids == [lane1ID])
|
||||
#expect(store.transient.lastActiveLaneID == lane1ID, "⌘N after the surprise files where the user was left")
|
||||
}
|
||||
|
||||
@Test("A foreign delete of the selected card's lane walks up, then sideways")
|
||||
func recoversSideways() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(lane1))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.selection.ids == [lane2ID])
|
||||
}
|
||||
|
||||
/// The app's own delete already chose its successor (04-interactions.md ▸ The map); a recovery
|
||||
/// firing on the echo would override it. 02-architecture.md's silent-vanish rule is what an
|
||||
/// app-mediated reload still gets.
|
||||
@Test("An app-mediated echo leaves the emptied selection exactly as the set rule left it")
|
||||
func appMediatedDoesNotRecover() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(store.transient.selection.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A reload that leaves the selection standing never re-aims it")
|
||||
func survivorsAreLeftAlone() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID, card2ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.selection.ids == [card2ID])
|
||||
}
|
||||
|
||||
// MARK: What actually gets said
|
||||
|
||||
@Test("A foreign reload speaks its digest exactly once")
|
||||
func foreignReloadSpeaksOnce() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Renamed")
|
||||
try fixture.card(card2, in: lane1, order: "2048", title: "Also renamed")
|
||||
await reload(store)
|
||||
|
||||
#expect(log.lines == ["Board changed: 2 cards edited"])
|
||||
}
|
||||
|
||||
@Test("An app-mediated echo says nothing at all")
|
||||
func appMediatedEchoIsSilent() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Renamed")
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(log.lines.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A reload that changed nothing says nothing")
|
||||
func quietReloadIsSilent() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
await reload(store)
|
||||
|
||||
#expect(log.lines.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The vanishing-focus sentence is the one the reload spends its sentence on")
|
||||
func vanishingFocusIsWhatIsHeard() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
let log = listen(to: store)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
await reload(store)
|
||||
|
||||
#expect(log.lines == ["Card 'Fix login' was deleted externally"])
|
||||
}
|
||||
|
||||
/// The seam pro-m1 fills. No base-edition operation passes a phrase today, so this is the one
|
||||
/// place the completion path is exercised end to end — including that the phrase is *consumed*
|
||||
/// rather than left armed for whatever reload comes next.
|
||||
@Test("A bracketed operation announces at completion, once, and never again")
|
||||
func bracketSpeaksOnceAtCompletion() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try store.performWholesale(announcing: "Pulled 3 commits") {
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "From the remote")
|
||||
}
|
||||
await reload(store, .appMediated)
|
||||
#expect(log.lines == ["Pulled 3 commits"], "the operation's result, not the churn inside it")
|
||||
|
||||
log.lines.removeAll()
|
||||
try fixture.card(card2, in: lane1, order: "2048", title: "An agent's edit")
|
||||
await reload(store)
|
||||
#expect(log.lines == ["Board changed: 1 card edited"], "the phrase was consumed, not carried forward")
|
||||
}
|
||||
|
||||
@Test("A bracket with no phrase to say stays quiet — every base-edition bracket today")
|
||||
func silentBracket() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try store.performWholesale {
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Rewritten")
|
||||
}
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(log.lines.isEmpty)
|
||||
}
|
||||
|
||||
/// "Including the read-only lock after a failed bracketed reload" — the banner appears, and the
|
||||
/// announcement is the row's own sentence.
|
||||
@Test("A failed bracketed reload announces the lock instead of the operation")
|
||||
func failedBracketAnnouncesTheLock() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try store.performWholesale(announcing: "Pulled 3 commits") {
|
||||
// A lane with no `order` fails the whole load (01-storage-format.md § Malformed input).
|
||||
try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n")
|
||||
}
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
||||
#expect(
|
||||
log.lines == [
|
||||
"Error: This board couldn't be re-read after the last operation — showing the last good view, read-only"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("The lock's clearance is announced too")
|
||||
func clearedLockIsAnnounced() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
try store.performWholesale {
|
||||
try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n")
|
||||
}
|
||||
await reload(store, .appMediated)
|
||||
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
||||
|
||||
let log = listen(to: store)
|
||||
try fixture.lane(lane1, order: "1024", title: "Todo")
|
||||
await reload(store)
|
||||
|
||||
#expect(store.readOnlyLock == nil)
|
||||
#expect(log.lines == ["The board is editable again"], "the healing outranks the digest of what healed it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The snapshot summarizer — 10-accessibility.md ▸ Live board announcements' "one polite digest per
|
||||
/// reload debounce", and (later) pro-m1's commit-message engine, whose counting rules these are too.
|
||||
///
|
||||
/// Every case here is **two real loads of a real board**, written and mutated on disk the way an
|
||||
/// outside writer mutates one: `BoardDiff` compares what a reload compares, and a suite that
|
||||
/// hand-built its `BoardModel` values could pass while disagreeing with the loader about what a
|
||||
/// board even is.
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
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 card4 = Ident.card4
|
||||
|
||||
/// Three lanes; two cards in the first, one in the second, none in the third.
|
||||
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: "First", body: "one")
|
||||
try fixture.card(card2, in: lane1, order: "2048", title: "Second", body: "two")
|
||||
try fixture.card(card3, in: lane2, order: "1024", title: "Third", body: "three")
|
||||
return fixture
|
||||
}
|
||||
|
||||
@Suite("BoardDiff")
|
||||
struct BoardDiffTests {
|
||||
|
||||
// MARK: - Nothing happened
|
||||
|
||||
@Test("Two reads of an untouched board are silent")
|
||||
func unchangedBoardIsSilent() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let diff = BoardDiff.between(try fixture.snapshot(), try fixture.snapshot())
|
||||
|
||||
#expect(diff.isSilent)
|
||||
#expect(!diff.boardChanged)
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
@Test("A retitled card is one edit")
|
||||
func retitledCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Renamed", body: "one")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.edited == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.added.isEmpty && diff.cards.moved.isEmpty && diff.cards.deleted.isEmpty)
|
||||
#expect(diff.lanes.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An edited body is an edit — the card's content is the whole point of one")
|
||||
func editedBody() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "First", body: "rewritten by an agent")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.edited == [ItemID(rawValue: card1)])
|
||||
}
|
||||
|
||||
/// The `modified` stamp is not on the board, so bumping it is not a change the board can show.
|
||||
/// It still trips `boardChanged`, which is the backstop's job — see `bareBoardChange`.
|
||||
@Test("A bumped timestamp is not an edit")
|
||||
func touchedTimestampIsNotAnEdit() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "First", body: "one", modified: "2026-07-29T09:00:00Z")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.isEmpty)
|
||||
#expect(diff.boardChanged, "the snapshot did differ — the backstop still fires")
|
||||
}
|
||||
|
||||
@Test("A new card in an existing lane is one addition")
|
||||
func addedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card4, in: lane2, order: "2048", title: "Fourth")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.added == [ItemID(rawValue: card4)])
|
||||
#expect(diff.cards.edited.isEmpty && diff.cards.moved.isEmpty)
|
||||
}
|
||||
|
||||
/// Deletion is a move into `.trash/` (01-storage-format.md § Deletion): the card leaves the
|
||||
/// board's universe, which is the only thing the digest describes.
|
||||
@Test("A card moved into the trash reads as deleted from the board")
|
||||
func deletedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.deleted == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.added.isEmpty, "it did not also arrive somewhere")
|
||||
}
|
||||
|
||||
@Test("A card moved between lanes is one move, not an addition plus a deletion")
|
||||
func movedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.moveFolder("\(lane1)/\(card1)", to: "\(lane2)/\(card1)")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.moved == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.added.isEmpty && diff.cards.deleted.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A reordered card is moved, not edited — `order` is the position axis")
|
||||
func reorderedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "4096", title: "First", body: "one")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.moved == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.edited.isEmpty)
|
||||
}
|
||||
|
||||
/// One card, one change to the board — two fragments counting it would read as two cards.
|
||||
@Test("A card that both moved and was retitled counts once, as moved")
|
||||
func movedAndEditedCountsOnce() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.moveFolder("\(lane1)/\(card1)", to: "\(lane2)/\(card1)")
|
||||
try fixture.card(card1, in: lane2, order: "1024", title: "Renamed", body: "one")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.moved == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.edited.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Lanes
|
||||
|
||||
@Test("A retitled lane is one lane edit and touches no card")
|
||||
func retitledLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(lane1, order: "1024", title: "Backlog")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.edited == [ItemID(rawValue: lane1)])
|
||||
#expect(diff.cards.isEmpty, "a lane's cards are diffed as cards, never folded into its own change")
|
||||
}
|
||||
|
||||
@Test("A resized lane is an edit — width is a lane's own visible property")
|
||||
func resizedLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(lane1, order: "1024", title: "Todo", width: 2)
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.edited == [ItemID(rawValue: lane1)])
|
||||
}
|
||||
|
||||
@Test("A reordered lane is moved, not edited")
|
||||
func reorderedLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(lane1, order: "5120", title: "Todo")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.moved == [ItemID(rawValue: lane1)])
|
||||
#expect(diff.lanes.edited.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Implied events don't steal the subject
|
||||
|
||||
/// 06-history-undo.md's composer discipline, which 10-accessibility.md applies to speech: a
|
||||
/// board that says "1 lane deleted, 2 cards deleted" has reported one event twice.
|
||||
@Test("A deleted lane does not also count the cards that went with it")
|
||||
func deletedLaneSwallowsItsCards() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(lane1))
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
|
||||
#expect(diff.cards.deleted.isEmpty, "the two cards left with their lane — that is the lane's event")
|
||||
}
|
||||
|
||||
@Test("A new lane does not also count the cards that arrived in it")
|
||||
func addedLaneSwallowsItsCards() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(Ident.lane4, order: "4096", title: "Blocked")
|
||||
try fixture.card(card4, in: Ident.lane4, order: "1024", title: "Fourth")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.added == [ItemID(rawValue: Ident.lane4)])
|
||||
#expect(diff.cards.added.isEmpty)
|
||||
}
|
||||
|
||||
/// The complement, and the reason the rule is stated on the *lane's* membership rather than on
|
||||
/// the card's presence: a card that survived by moving into the new lane is its own event.
|
||||
@Test("A card that moved into a new lane is still a move of its own")
|
||||
func cardMovedIntoANewLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(Ident.lane4, order: "4096", title: "Blocked")
|
||||
try fixture.moveFolder("\(lane1)/\(card1)", to: "\(Ident.lane4)/\(card1)")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.added == [ItemID(rawValue: Ident.lane4)])
|
||||
#expect(diff.cards.moved == [ItemID(rawValue: card1)])
|
||||
}
|
||||
|
||||
// MARK: - The trash and the backstop
|
||||
|
||||
/// A purge moves nothing on the board, and the trash column is hidden by default — not worth
|
||||
/// interrupting a VoiceOver user for.
|
||||
@Test("Churn inside the trash is not a change to the board")
|
||||
func trashChurnIsSilent() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.trashCard(card4, order: "1024", title: "Old")
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(".trash/\(card4)"))
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.isSilent)
|
||||
}
|
||||
|
||||
/// "Silence about a mutating board is a lie" — so a change no bucket counts still reports
|
||||
/// *something*, which `AccessibilityPhrases.boardChanged(_:)` speaks as the bare sentence.
|
||||
@Test("A renamed board is a bare board change with no buckets")
|
||||
func bareBoardChange() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.board(title: "Renamed")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.boardChanged)
|
||||
#expect(diff.cards.isEmpty && diff.lanes.isEmpty)
|
||||
#expect(!diff.isSilent)
|
||||
}
|
||||
}
|
||||
@@ -388,8 +388,13 @@ struct SelectionHeadTests {
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(store.selection.isEmpty)
|
||||
#expect(store.transient.selectionHead == nil)
|
||||
// The crossing dropped the head, which is this test's rule. What stands afterwards is
|
||||
// 10-accessibility.md's vanishing-focus recovery, layered on top: the selection had emptied
|
||||
// and the cursor's card had gone, so focus lands on the card's lane and takes the head with
|
||||
// it (`BoardAnnouncerStoreTests`, where the recovery itself is pinned). The card's own head
|
||||
// reference is gone either way, which is what "a container crossing is a vanish" claims.
|
||||
#expect(store.selection.ids == [lane1])
|
||||
#expect(store.transient.selectionHead == lane1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,8 +465,12 @@ struct SelectionAnchorTests {
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||
await reload(store)
|
||||
#expect(store.selection.isEmpty)
|
||||
#expect(store.transient.selectionAnchor == nil)
|
||||
// The anchor's card is gone — the rule this test exists for. The selection is not empty
|
||||
// afterwards because the reload emptied it *and* took the cursor with it, which is
|
||||
// 10-accessibility.md's vanishing-focus case: focus recovers to the card's lane, and a
|
||||
// one-item selection made by any route is its own anchor (`BoardAnnouncerStoreTests`).
|
||||
#expect(store.selection.ids == [lane1])
|
||||
#expect(store.transient.selectionAnchor == lane1)
|
||||
}
|
||||
|
||||
@Test("A container crossing is a vanish for the anchor too")
|
||||
|
||||
@@ -118,6 +118,55 @@ struct WriterFixture {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshots
|
||||
|
||||
/// The half of the fixture the **snapshot-comparison** suites need (`BoardDiffTests`,
|
||||
/// `BoardAnnouncerTests`): boards written as files and read back through the real loader.
|
||||
///
|
||||
/// They compare `BoardModel` values, and a hand-assembled model would be assembling something
|
||||
/// `BoardLoader` can never produce — a lane with a malformed `order`, a card whose `document` does
|
||||
/// not match its fields. Writing bytes and loading them is the only way the two snapshots in a diff
|
||||
/// are the two snapshots a reload would actually have compared.
|
||||
extension WriterFixture {
|
||||
|
||||
/// `<root>/index.md`, the one file every board must have.
|
||||
@discardableResult
|
||||
func board(title: String = "Board", body: String = "Board description.") throws -> URL {
|
||||
try item("", "---\nschema: 1\ntitle: \(title)\n---\n\(body)\n")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func lane(_ id: String, order: String, title: String, width: Int? = nil, body: String = "") throws -> URL {
|
||||
let widthLine = width.map { "width: \($0)\n" } ?? ""
|
||||
return try item(id, "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(widthLine)---\n\(body)\n")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func card(
|
||||
_ id: String,
|
||||
in laneID: String,
|
||||
order: String,
|
||||
title: String,
|
||||
body: String = "",
|
||||
modified: String? = nil
|
||||
) throws -> URL {
|
||||
let modifiedLine = modified.map { "modified: \($0)\n" } ?? ""
|
||||
return try item("\(laneID)/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(modifiedLine)---\n\(body)\n")
|
||||
}
|
||||
|
||||
/// A card written straight into `<root>/.trash/` — the materialized trash's shape, for the
|
||||
/// diff rule that says churn in there is not a change to the board.
|
||||
@discardableResult
|
||||
func trashCard(_ id: String, order: String, title: String) throws -> URL {
|
||||
try item(".trash/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\n")
|
||||
}
|
||||
|
||||
/// The board as the loader reads it right now — the value a reload would have landed.
|
||||
func snapshot() throws -> BoardModel {
|
||||
try BoardLoader.load(boardRoot: root).model
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Move/copy identities
|
||||
|
||||
/// Literal UUID-shaped names for the move/copy suites, which need more of them than
|
||||
|
||||
Reference in New Issue
Block a user