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) } }