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
284 lines
11 KiB
Swift
284 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|