background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
567 lines
25 KiB
Swift
567 lines
25 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
|
|
/// A fourth lane identity, used only by the trash's own rows — a row must never share an id with a
|
|
/// live lane, which on a real board it cannot (board-wide uniqueness spans both containers).
|
|
private let trashedLaneID = Ident.lane4
|
|
|
|
/// 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. The other half of the ruling is `ShownTrashDiffTests`.
|
|
@Test("Churn inside the hidden 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)
|
|
}
|
|
}
|
|
|
|
// MARK: - The shown trash
|
|
|
|
/// **The digest covers the trash only while the trash lane is shown** (10-accessibility.md ▸ Live
|
|
/// board announcements, ruled 2026-07-29): "a foreign purge, restore, or Empty Trash joins the
|
|
/// digest like any lane's churn — a user working in the shown trash must hear it emptied under
|
|
/// them; while hidden, trash churn stays silent".
|
|
///
|
|
/// Every case is asserted **both ways round the toggle**, because the ruling is a claim about the
|
|
/// difference between them — and because the property the implementation leans on is that the flag
|
|
/// widens the universe without re-wording a single event a hidden trash could already see
|
|
/// (`BoardDiff.between(_:_:includingTrash:)`).
|
|
@Suite("BoardDiff ▸ the shown trash")
|
|
struct ShownTrashDiffTests {
|
|
|
|
@Test("A purge is a deletion while the trash is shown, and silence while it is hidden")
|
|
func purgeCountsWhileShown() 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 after = try fixture.snapshot()
|
|
|
|
#expect(BoardDiff.between(before, after, includingTrash: true).cards.deleted == [ItemID(rawValue: card4)])
|
|
#expect(BoardDiff.between(before, after).isSilent)
|
|
}
|
|
|
|
/// The sentence the ruling is written around: "a user working in the shown trash must hear it
|
|
/// emptied under them".
|
|
@Test("Empty Trash reads as its cards deleted, one per card")
|
|
func emptyTrashCountsItsCards() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashCard(card4, order: "1024", title: "Old")
|
|
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
|
|
let before = try fixture.snapshot()
|
|
|
|
try FileManager.default.removeItem(at: fixture.url(".trash"))
|
|
let after = try fixture.snapshot()
|
|
|
|
let diff = BoardDiff.between(before, after, includingTrash: true)
|
|
#expect(diff.cards.deleted == [ItemID(rawValue: card4), ItemID(rawValue: card1)])
|
|
#expect(diff.cards.added.isEmpty && diff.cards.moved.isEmpty)
|
|
#expect(BoardDiff.between(before, after).isSilent)
|
|
}
|
|
|
|
@Test("A card dropped straight into the shown trash by an outside writer is an arrival")
|
|
func arrivalIntoTheTrashCountsWhileShown() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.trashCard(card4, order: "1024", title: "Filed by an agent")
|
|
let after = try fixture.snapshot()
|
|
|
|
#expect(BoardDiff.between(before, after, includingTrash: true).cards.added == [ItemID(rawValue: card4)])
|
|
#expect(BoardDiff.between(before, after).isSilent)
|
|
}
|
|
|
|
@Test("A trash card retitled in place is an edit while the trash is shown")
|
|
func editInsideTheTrashCountsWhileShown() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashCard(card4, order: "1024", title: "Old")
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.trashCard(card4, order: "1024", title: "Renamed in the trash")
|
|
let after = try fixture.snapshot()
|
|
|
|
#expect(BoardDiff.between(before, after, includingTrash: true).cards.edited == [ItemID(rawValue: card4)])
|
|
#expect(BoardDiff.between(before, after).isSilent)
|
|
}
|
|
|
|
@Test("A trash card reordered in place is a move while the trash is shown")
|
|
func reorderInsideTheTrashCountsWhileShown() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashCard(card4, order: "1024", title: "Old")
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.trashCard(card4, order: "4096", title: "Old")
|
|
let after = try fixture.snapshot()
|
|
|
|
#expect(BoardDiff.between(before, after, includingTrash: true).cards.moved == [ItemID(rawValue: card4)])
|
|
#expect(BoardDiff.between(before, after).isSilent)
|
|
}
|
|
|
|
/// The backstop follows the buckets: a stamp bumped on a trash card changes nothing anyone can
|
|
/// see, but the snapshot did differ, and while the column is shown that is a mutating board.
|
|
@Test("A bumped stamp on a trash card trips the backstop only while the trash is shown")
|
|
func trashBackstopFollowsVisibility() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashCard(card4, order: "1024", title: "Old")
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.item(
|
|
".trash/\(card4)",
|
|
"---\nschema: 1\ntitle: Old\norder: 1024\nmodified: 2026-07-29T09:00:00Z\n---\n\n"
|
|
)
|
|
let after = try fixture.snapshot()
|
|
|
|
let shown = BoardDiff.between(before, after, includingTrash: true)
|
|
#expect(shown.boardChanged)
|
|
#expect(shown.cards.isEmpty, "a stamp is not a change a card's face can show")
|
|
#expect(BoardDiff.between(before, after).isSilent)
|
|
}
|
|
|
|
// MARK: The crossings — same word on both sides of the toggle
|
|
|
|
/// "A card entering the trash already counts as a card deleted … which is the user's own reading
|
|
/// of both." Showing the column must not re-word a delete into "1 card moved".
|
|
@Test("A delete is a departure whether the trash is shown or hidden")
|
|
func deleteIsADepartureEitherWay() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
|
|
let after = try fixture.snapshot()
|
|
|
|
for shown in [true, false] {
|
|
let diff = BoardDiff.between(before, after, includingTrash: shown)
|
|
#expect(diff.cards.deleted == [ItemID(rawValue: card1)], "shown: \(shown)")
|
|
#expect(diff.cards.moved.isEmpty && diff.cards.added.isEmpty, "shown: \(shown)")
|
|
}
|
|
}
|
|
|
|
@Test("A restore is an arrival whether the trash is shown or hidden")
|
|
func restoreIsAnArrivalEitherWay() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashCard(card4, order: "1024", title: "Old")
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.move(".trash/\(card4)", toLane: lane2, card: card4)
|
|
let after = try fixture.snapshot()
|
|
|
|
for shown in [true, false] {
|
|
let diff = BoardDiff.between(before, after, includingTrash: shown)
|
|
#expect(diff.cards.added == [ItemID(rawValue: card4)], "shown: \(shown)")
|
|
#expect(diff.cards.moved.isEmpty && diff.cards.deleted.isEmpty, "shown: \(shown)")
|
|
}
|
|
}
|
|
|
|
/// The implied-events rule reaches the crossings: an agent that empties a lane into the trash
|
|
/// and removes the folder made one change to the board, and a shown trash must not turn it into
|
|
/// "1 lane deleted, 2 cards deleted".
|
|
@Test("A lane emptied into the shown trash and removed is still just the lane's event")
|
|
func deletedLaneStillSwallowsItsCardsWhenTheyLandInTheTrash() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
|
|
try fixture.move("\(lane1)/\(card2)", toTrash: card2)
|
|
try FileManager.default.removeItem(at: fixture.url(lane1))
|
|
let diff = BoardDiff.between(before, try fixture.snapshot(), includingTrash: true)
|
|
|
|
#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")
|
|
}
|
|
|
|
// MARK: - The column's other kind
|
|
|
|
/// The crossing rule at the lane level (lanes rejoined the trash 2026-07-29): a lane moved into
|
|
/// `.trash/` is a **delete**, and it reads the same shown or hidden, because the lane left
|
|
/// `lanes` either way — which is exactly the "1 lane deleted" event the user watched happen.
|
|
@Test("A lane moved into the trash is deleted, shown or hidden, and never a move")
|
|
func laneIntoTheTrashIsADeletion() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.moveFolder(lane1, to: ".trash/\(lane1)")
|
|
let after = try fixture.snapshot()
|
|
|
|
for shown in [true, false] {
|
|
let diff = BoardDiff.between(before, after, includingTrash: shown)
|
|
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
|
|
#expect(diff.lanes.moved.isEmpty)
|
|
#expect(diff.cards.deleted.isEmpty, "its cards went with it — that is the lane's event")
|
|
}
|
|
}
|
|
|
|
/// And back out again: a restored lane is an **arrival**, not a move.
|
|
@Test("A lane restored out of the trash is an addition")
|
|
func laneOutOfTheTrashIsAnArrival() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.moveFolder(lane1, to: ".trash/\(lane1)")
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.moveFolder(".trash/\(lane1)", to: lane1)
|
|
let after = try fixture.snapshot()
|
|
|
|
for shown in [true, false] {
|
|
let diff = BoardDiff.between(before, after, includingTrash: shown)
|
|
#expect(diff.lanes.added == [ItemID(rawValue: lane1)])
|
|
#expect(diff.lanes.moved.isEmpty)
|
|
#expect(diff.cards.added.isEmpty, "its cards arrived with it")
|
|
}
|
|
}
|
|
|
|
/// The news the ruling actually adds: churn that never leaves the container. A purged **row** is
|
|
/// counted while the column is shown and silent while it is hidden, exactly as a purged card is.
|
|
@Test("A purged lane row is a lane deletion while the trash is shown, and silence while hidden")
|
|
func purgedLaneRowCountsWhileShown() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
|
|
let before = try fixture.snapshot()
|
|
|
|
try FileManager.default.removeItem(at: fixture.url(".trash/\(trashedLaneID)"))
|
|
let after = try fixture.snapshot()
|
|
|
|
#expect(BoardDiff.between(before, after, includingTrash: true).lanes.deleted == [ItemID(rawValue: trashedLaneID)])
|
|
#expect(BoardDiff.between(before, after).isSilent)
|
|
}
|
|
|
|
/// "… 41 cards and 2 lanes containing 9 more cards" is the confirmation's sentence; this is the
|
|
/// digest's: an Empty Trash over a mixed container must not understate what went.
|
|
@Test("Empty Trash counts the lane rows alongside the cards")
|
|
func emptyTrashCountsBothKinds() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashCard(card4, order: "1024", title: "Old")
|
|
try fixture.trashedLane(trashedLaneID, order: "512", title: "Done")
|
|
let before = try fixture.snapshot()
|
|
|
|
try FileManager.default.removeItem(at: fixture.url(".trash/\(card4)"))
|
|
try FileManager.default.removeItem(at: fixture.url(".trash/\(trashedLaneID)"))
|
|
let diff = BoardDiff.between(before, try fixture.snapshot(), includingTrash: true)
|
|
|
|
#expect(diff.cards.deleted == [ItemID(rawValue: card4)])
|
|
#expect(diff.lanes.deleted == [ItemID(rawValue: trashedLaneID)])
|
|
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 1 card deleted, 1 lane deleted")
|
|
}
|
|
|
|
/// The row's *rendered* content is its title, and only that: it takes no styling accents, so a
|
|
/// colour an agent wrote onto a trashed folder changes nothing anyone can see.
|
|
@Test("A retitled row is an edit while shown; a restyled one is not an edit at all")
|
|
func rowContentIsItsTitle() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.item(".trash/\(trashedLaneID)", "---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\n---\n\n")
|
|
#expect(BoardDiff.between(before, try fixture.snapshot(), includingTrash: true).lanes.edited
|
|
== [ItemID(rawValue: trashedLaneID)])
|
|
|
|
let retitled = try fixture.snapshot()
|
|
try fixture.item(
|
|
".trash/\(trashedLaneID)",
|
|
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: {color: blue}\n---\n\n"
|
|
)
|
|
let styled = BoardDiff.between(retitled, try fixture.snapshot(), includingTrash: true)
|
|
#expect(styled.lanes.isEmpty, "no accent is rendered, so nothing visible changed")
|
|
#expect(styled.boardChanged, "the backstop still catches it — the bytes did change")
|
|
}
|
|
|
|
/// A row that moved rank in the column is a move while shown, like a trash card reordered in
|
|
/// place — the position axis is the same axis whatever the kind.
|
|
@Test("A reordered lane row is a move while the trash is shown")
|
|
func reorderedRowIsAMove() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
|
|
let before = try fixture.snapshot()
|
|
|
|
try fixture.trashedLane(trashedLaneID, order: "256", title: "Done")
|
|
let after = try fixture.snapshot()
|
|
|
|
#expect(BoardDiff.between(before, after, includingTrash: true).lanes.moved == [ItemID(rawValue: trashedLaneID)])
|
|
#expect(BoardDiff.between(before, after).isSilent)
|
|
}
|
|
}
|