Files
lanework/KanbanTests/ViewEquatableTests.swift
T
rzen c87616f3fb Comments join attachments on the card face — a quiet bubble-and-count chip, present-only
A card whose thread holds one comment or more now draws a second trailing chip beside the
paperclip: a secondary-tinted bubble glyph plus its count, shown only when the count is above
zero (design ruling 2026-08-09, card e729e30a). Same styling family as the attachments chip —
caption size, secondary tint, decorative and hidden outright from the accessibility tree — but
this one carries a visible count rather than staying icon-only, per the ruling's own "bubble-style
SF Symbol + count." It sits after the attachments chip at the row's trailing edge, in both the
live title row and the drag replica.

The count is a new `Card.commentCount` field the loader fills with a readdir over `comments/`'s
identity-shaped children that carry their own `index.md` — `BoardLoader.commentCount(in:)`, built
on the same `identityShapedChildren` predicate a trash entry's held-card count already uses. Never
a parse: `.draft` and `.trash/` are excluded for free, the same dot-prefixed hidden-entry skip
`CommentThread.load` documents for both, so the walk stays exactly the O(cards) shape
01-storage-format.md § Enhanced schema already commits to. Because the count rides inside the
`card: Card` parameter `CardFaceView` already takes — not a new parameter of its own — drawing the
chip costs nothing beyond a field read on an already-compared value: no new Observable read joins
the body, and the equatable gate already covers it via `Card`'s synthesized `Equatable`.

The one divergence from the comments pane's parsed count is documented rather than hidden: a
comment folder whose `index.md` exists but fails to parse is a `Stray` the thread read excludes by
opening and rejecting it, a cost this readdir does not pay. The face may then read one comment
high until that folder is fixed or removed — the trade the ruling's "cheap directory-entry count…
not a parse" asks for, over paying full parse cost on every card of every load. Every well-formed
comment, and every card with no malformed one, agrees with the pane exactly.

VoiceOver: `AccessibilityPhrases.cardValue` gains a `comments: Int` parameter, appended after
attachments and before the cut-pending phrase — the same left-to-right order the two chips draw
in, so a sighted read and a VoiceOver read never disagree about which comes first. The trashed
lane row's own call site (an opaque unit with no comments to speak of) passes `comments: 0`.

Docs: DESIGN/03-board-ui.md's card-face section describes both chips and retires the stale "closed
with no growth" sentence, honestly recording the 2026-08-09 growth (the hero banner landed hours
earlier, this chip after it) as exposure of facts the card already carries rather than a body
excerpt. DESIGN/10-accessibility.md's flattened-element sentence gains the comment count.
DESIGN/01-storage-format.md's Enhanced schema paragraph records the chip as shipped. WISHLIST #9
is marked shipped in place — not renumbered, since #10 and #11 are cross-referenced elsewhere.

Tests: CardCommentCountListingTests (BoardLoaderTests.swift) pins the readdir against a synthetic
tree — no comments/ folder, an empty one, non-identity-shaped and index-less strays excluded,
.draft/.trash/ excluded for free, agreement with CommentThread.load's parsed count in the
well-formed case, and the one documented divergence on a malformed index.md.
AccessibilityPhrasesTests covers cardValue's new parameter alone, alongside attachments, and
all three fragments together. ViewEquatableTests pins that a comment landing on a card is a gate
difference. BoardRenderPerformanceTests adds a render-cost guard: one comment added to one card
on a hosted 180-card board re-renders a handful of bodies, not the board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 01:21:33 -04:00

647 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Foundation
import Testing
@testable import Kanban
/// The board strip's three rebuild gates — `LaneView.==`, `CardFaceView.==` and
/// `TrashLaneRowView.==`, applied through `.equatable()` at `BoardView.laneSlot`,
/// `LaneView.scrollableCards` and `TrashLaneView.scrollableCards`.
///
/// They exist for the drag: `BoardView`'s body reads the drag session, so **every drop-proposal
/// change re-evaluates the whole strip**, and a lane's body reads it too, so every proposal change
/// reached every card face as well. The gates are what turn "the cursor moved" into "the one lane
/// under it re-runs" rather than "every lane and every face on the board re-runs".
///
/// What these tests pin is the *comparison list*, because that is where a gate goes wrong in either
/// direction. Too strict and the gate does nothing — the strip rebuilds the drop context and the
/// card opener on every body pass, so any view comparing closures is unequal every time. Too loose
/// and the board stops repainting — an edited card must make its lane a different value, and a
/// newly selected card must make its face one.
///
/// **Selection is on the compared side now, and these tests are where that is held.** It used to be
/// the headline example of the Observation half these gates deliberately do not cover: every card
/// face read `store.selection` for itself, so one click invalidated all of them directly and
/// `.equatable()` never got a say (measured at 180 bodies per marquee sample on the 6×30 fixture —
/// RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies). A face now takes `isSelected`
/// and `selectedCount` from its parent, which means the gate is what decides whether a selection
/// change repaints a given face — so it has to be unequal exactly when the flags differ, and
/// `MarqueeRenderCostTests` measures the consequence.
///
/// They still do **not** pin the rest of the Observation half, and cannot: `store.transient`'s
/// pending cut and rename editor, `drops.session`'s proposal and the rest invalidate these views
/// directly. That is the design — the gate only suppresses *parent-driven* re-evaluation.
///
/// Boards are real loads off real temp trees, `SearchFilterTests`' reason: a hand-assembled `Lane`
/// would be comparing something `BoardLoader` can never produce, and "an edit makes the lane
/// unequal" is only worth asserting against the values a reload actually lands.
// MARK: - Fixtures
/// Two lanes, two cards in the first — enough for a lane value that changes when a card under it
/// does, and for two card values that differ from each other.
@MainActor
private func makeFixture() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.board()
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "The auth flow.")
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Polish copy", body: "Tighten it.")
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
return fixture
}
/// A drop context with the four members the gates compare made explicit, and the three closures
/// freshly allocated on every call — which is exactly what `BoardView.dropContext` does per body
/// pass, and the thing the gates must survive.
@MainActor
private func makeDrops(
store: BoardStore,
session: DragSession,
registry: LaneDropRegistry,
gap: CGFloat = 12
) -> BoardDropContext {
BoardDropContext(
store: store,
session: session,
registry: registry,
gap: gap,
window: { nil },
stripFrame: { .zero },
standard: { 260 }
)
}
@MainActor
private func makeLane(
store: BoardStore,
lane: Lane,
columns: Int = 1,
slotWidth: CGFloat = 260,
drops: BoardDropContext,
marquee: MarqueeControl,
openCard: @escaping @MainActor (ItemID) -> Void = { _ in }
) -> LaneView {
LaneView(
store: store,
lane: lane,
columns: columns,
slotWidth: slotWidth,
drops: drops,
marquee: marquee,
openCard: openCard
)
}
private func firstLane(_ model: BoardModel) throws -> Lane {
try #require(model.lanes.first { $0.id == ItemID(rawValue: Ident.lane1) })
}
private func firstCard(_ model: BoardModel) throws -> Card {
try #require(try firstLane(model).cards.first { $0.id == ItemID(rawValue: Ident.card1) })
}
// MARK: - The strip's gate
@MainActor
@Suite("LaneView — the strip's rebuild gate")
struct LaneViewEquatableTests {
@Test("Identical inputs compare equal — the pass a proposal change would otherwise rebuild")
func identicalInputsAreEqual() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lane = try firstLane(fixture.snapshot())
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
#expect(makeLane(store: store, lane: lane, drops: drops, marquee: marquee)
== makeLane(store: store, lane: lane, drops: drops, marquee: marquee))
}
@Test("A rebuilt drop context and a fresh opener still compare equal — the whole point of the gate")
func freshClosuresAreNotADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lane = try firstLane(fixture.snapshot())
let session = DragSession()
let registry = LaneDropRegistry()
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
// Two body passes of `BoardView`: same store, same session, same registry, same gap — three
// brand-new closures in the context and a brand-new `openCard` besides. A gate that compared
// any of them would be unequal here, which is to say it would never suppress anything.
let before = makeLane(
store: store,
lane: lane,
drops: makeDrops(store: store, session: session, registry: registry),
marquee: marquee,
openCard: { _ in }
)
let after = makeLane(
store: store,
lane: lane,
drops: makeDrops(store: store, session: session, registry: registry),
marquee: marquee,
openCard: { _ in Issue.record("the gate must not care which opener it holds") }
)
#expect(before == after)
}
@Test("An edited card makes its lane unequal — the gate never withholds a repaint")
func anEditUnderTheLaneIsADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let before = try firstLane(fixture.snapshot())
// A foreign edit, the way a reload lands one: `Lane` is `Equatable` through its cards, so a
// card's new title is a new lane value even though the lane's own frontmatter is untouched.
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix auth", body: "The auth flow.")
let after = try firstLane(fixture.snapshot())
#expect(before != after)
#expect(makeLane(store: store, lane: before, drops: drops, marquee: marquee)
!= makeLane(store: store, lane: after, drops: drops, marquee: marquee))
}
@Test("The two layout figures the strip resolves are compared: a resize tick and a width change")
func theStripsLayoutFiguresAreCompared() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lane = try firstLane(fixture.snapshot())
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let base = makeLane(store: store, lane: lane, columns: 1, slotWidth: 260, drops: drops, marquee: marquee)
// `columns` follows the resize session's snapped unit count and `slotWidth` the strip's
// standard width — neither is readable off `lane`, so neither can ride in on its value.
#expect(base != makeLane(store: store, lane: lane, columns: 2, slotWidth: 260, drops: drops, marquee: marquee))
#expect(base != makeLane(store: store, lane: lane, columns: 1, slotWidth: 532, drops: drops, marquee: marquee))
}
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
func theCollaboratorsAreComparedByIdentity() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let other = try BoardStore(rootURL: fixture.root)
let lane = try firstLane(fixture.snapshot())
let session = DragSession()
let registry = LaneDropRegistry()
let bandSession = MarqueeSession()
let bandRegistry = MarqueeTargetRegistry()
let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
let drops = makeDrops(store: store, session: session, registry: registry)
let base = makeLane(store: store, lane: lane, drops: drops, marquee: marquee)
// A different board window in every direction the two structs can differ.
#expect(base != makeLane(store: other, lane: lane, drops: drops, marquee: marquee))
#expect(base != makeLane(
store: store, lane: lane,
drops: makeDrops(store: store, session: DragSession(), registry: registry),
marquee: marquee
))
#expect(base != makeLane(
store: store, lane: lane,
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
marquee: marquee
))
#expect(base != makeLane(
store: store, lane: lane,
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
marquee: marquee
))
#expect(base != makeLane(
store: store, lane: lane, drops: drops,
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store)
))
#expect(base != makeLane(
store: store, lane: lane, drops: drops,
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store)
))
}
}
// MARK: - The lane's gate
@MainActor
@Suite("CardFaceView — the lane's rebuild gate")
struct CardFaceViewEquatableTests {
@Test("Identical inputs compare equal, fresh opener and fresh drop context included")
func identicalInputsAreEqual() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let card = try firstCard(fixture.snapshot())
let session = DragSession()
let registry = LaneDropRegistry()
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
// The `.board` role's payload is a closure the lane rebuilds on every pass; comparing it
// would make every face on the board unequal on every proposal change, which is the rebuild
// this gate exists to stop.
let before = CardFaceView(
store: store,
card: card,
role: .board(openCard: { _ in }),
marquee: marquee,
drops: makeDrops(store: store, session: session, registry: registry),
hero: nil, isSelected: false,
selectedCount: 1
)
let after = CardFaceView(
store: store,
card: card,
role: .board(openCard: { _ in Issue.record("the gate must not care which opener it holds") }),
marquee: marquee,
drops: makeDrops(store: store, session: session, registry: registry),
hero: nil, isSelected: false,
selectedCount: 1
)
#expect(before == after)
}
@Test("Selected-ness is a compared input — the whole reason the faces stopped reading the store")
func selectednessIsADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let card = try firstCard(fixture.snapshot())
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
let unselected = CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1
)
// The gate is now the *only* thing standing between a click and this face's repaint: nothing
// in this body reads `store.selection` any more, so a gate that swallowed the flag would
// leave a selected card wearing no accent ring at all.
#expect(unselected != CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1
))
// And the count, which the drag replica's fan and count badge are drawn from: a card that is
// still selected but now travels with four others has a different image under the cursor.
let alone = CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1
)
#expect(alone != CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 5
))
}
/// **The hero is a compared input too** (03-board-ui.md § Card face ▸ Hero image) — a resolution
/// the parent does, like selected-ness, and the one input that changes the face's *height*. A gate
/// that swallowed it would leave a card banding a picture it no longer names, or naming one it
/// never draws.
@Test("The resolved hero file is a difference")
func theHeroIsADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let card = try firstCard(fixture.snapshot())
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
let sketch = fixture.root.appendingPathComponent("sketch.png")
func face(_ hero: URL?) -> CardFaceView {
CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: hero, isSelected: false, selectedCount: 1
)
}
#expect(face(nil) == face(nil))
#expect(face(sketch) == face(sketch))
#expect(face(nil) != face(sketch))
#expect(face(sketch) != face(fixture.root.appendingPathComponent("cover.png")))
}
/// **The comment count is a difference too** (design ruling 2026-08-09, card e729e30a) — but
/// unlike the hero it needs no parameter of its own: it rides inside `card`, a snapshot field
/// the loader fills with a readdir (`Card.commentCount`), so this pins that `Card`'s synthesized
/// `Equatable` is not quietly excluding it from the gate the way a hand-written `==` might.
@Test("A comment added to the card is a difference")
func aCommentCountChangeIsADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession()
let registry = LaneDropRegistry()
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: session, registry: registry)
let before = try firstCard(fixture.snapshot())
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)/comments/\(UUID().uuidString.lowercased())",
"---\nschema: 1\n---\n"
)
let after = try firstCard(fixture.snapshot())
#expect(before.commentCount == 0)
#expect(after.commentCount == 1)
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
}
@Test("An edited card is unequal — the gate never withholds a repaint")
func anEditedCardIsADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession()
let registry = LaneDropRegistry()
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: session, registry: registry)
let before = try firstCard(fixture.snapshot())
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix auth", body: "The auth flow.")
let after = try firstCard(fixture.snapshot())
#expect(before != after)
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
// And a different card, which is the ordinary within-lane case.
let sibling = try #require(try firstLane(fixture.snapshot()).cards.first {
$0.id == ItemID(rawValue: Ident.card2)
})
#expect(CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
!= CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
}
@Test("The two homes are never equal, and the trash's confirmation host is compared by identity")
func theRolesHomeIsCompared() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let card = try firstCard(fixture.snapshot())
let session = DragSession()
let registry = LaneDropRegistry()
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: session, registry: registry)
let confirmations = TrashConfirmations()
let board = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
// The role decides which container a click selects in, whether the face has an Open gesture
// at all, and whether Delete is the permanent one — never a difference to swallow.
#expect(board != trash)
#expect(trash == CardFaceView(store: store, card: card,
role: .trash(confirmations: confirmations),
marquee: marquee, drops: drops,
hero: nil, isSelected: false, selectedCount: 1))
// Window-lived state, so identity is meaningful as well as cheap.
#expect(trash != CardFaceView(store: store, card: card,
role: .trash(confirmations: TrashConfirmations()),
marquee: marquee, drops: drops,
hero: nil, isSelected: false, selectedCount: 1))
}
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
func theCollaboratorsAreComparedByIdentity() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let other = try BoardStore(rootURL: fixture.root)
let card = try firstCard(fixture.snapshot())
let session = DragSession()
let registry = LaneDropRegistry()
let bandSession = MarqueeSession()
let bandRegistry = MarqueeTargetRegistry()
let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
let drops = makeDrops(store: store, session: session, registry: registry)
let base = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
#expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops,
hero: nil, isSelected: false, selectedCount: 1))
#expect(base != CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
drops: makeDrops(store: store, session: DragSession(), registry: registry),
hero: nil, isSelected: false, selectedCount: 1
))
#expect(base != CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
hero: nil, isSelected: false, selectedCount: 1
))
#expect(base != CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
hero: nil, isSelected: false, selectedCount: 1
))
#expect(base != CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store),
drops: drops, hero: nil, isSelected: false, selectedCount: 1
))
#expect(base != CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store),
drops: drops, hero: nil, isSelected: false, selectedCount: 1
))
}
}
// MARK: - The trash column's gate
/// `TrashLaneRowView.==` — the card face's gate at the other level, and new with the selection
/// change: the column's body did not read the selection until the rows stopped reading it for
/// themselves, so until then there was nothing here worth suppressing
/// (`TrashLaneView.scrollableCards`).
@MainActor
@Suite("TrashLaneRowView — the trash column's rebuild gate")
struct TrashLaneRowViewEquatableTests {
/// The opaque unit the column draws — hand-assembled rather than loaded, because a trashed lane
/// is exactly the row's whole input and `BoardLoader` needs a real deletion to produce one.
private func makeRow(title: String, heldCards: Int = 5) -> TrashedLane {
TrashedLane(
id: ItemID(rawValue: Ident.lane3),
schema: 1,
title: .valid(title),
modified: .missing,
order: 1024,
heldCards: heldCards,
document: FrontmatterDocument(body: "")
)
}
@MainActor
private func makeView(
store: BoardStore,
lane: TrashedLane,
confirmations: TrashConfirmations,
drops: BoardDropContext,
marquee: MarqueeControl,
isSelected: Bool = false,
selectedCount: Int = 1
) -> TrashLaneRowView {
TrashLaneRowView(
store: store,
lane: lane,
confirmations: confirmations,
drops: drops,
marquee: marquee,
isSelected: isSelected,
selectedCount: selectedCount
)
}
@Test("Identical inputs compare equal, a freshly rebuilt drop context included")
func identicalInputsAreEqual() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession()
let registry = LaneDropRegistry()
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let confirmations = TrashConfirmations()
let lane = makeRow(title: "Retired")
// Two body passes of the window: same collaborators, three brand-new closures in the drop
// context each time — a gate that compared any of them would never suppress anything.
#expect(makeView(
store: store, lane: lane, confirmations: confirmations,
drops: makeDrops(store: store, session: session, registry: registry), marquee: marquee
) == makeView(
store: store, lane: lane, confirmations: confirmations,
drops: makeDrops(store: store, session: session, registry: registry), marquee: marquee
))
}
@Test("A different lane value is unequal — the gate never withholds a repaint")
func aDifferentLaneValueIsADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
let confirmations = TrashConfirmations()
let base = makeView(store: store, lane: makeRow(title: "Retired"),
confirmations: confirmations, drops: drops, marquee: marquee)
// The row draws exactly two things — the title and the held-card count — so both have to be
// differences, and `TrashedLane` being `Equatable` is what makes them one comparison.
#expect(base != makeView(store: store, lane: makeRow(title: "Retired lanes"),
confirmations: confirmations, drops: drops, marquee: marquee))
#expect(base != makeView(store: store, lane: makeRow(title: "Retired", heldCards: 6),
confirmations: confirmations, drops: drops, marquee: marquee))
}
@Test("Selected-ness and the selection's size are compared, the card face's rule")
func selectednessIsADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
let confirmations = TrashConfirmations()
let lane = makeRow(title: "Retired")
let base = makeView(store: store, lane: lane, confirmations: confirmations,
drops: drops, marquee: marquee)
#expect(base != makeView(store: store, lane: lane, confirmations: confirmations,
drops: drops, marquee: marquee, isSelected: true))
#expect(makeView(store: store, lane: lane, confirmations: confirmations, drops: drops,
marquee: marquee, isSelected: true, selectedCount: 1)
!= makeView(store: store, lane: lane, confirmations: confirmations, drops: drops,
marquee: marquee, isSelected: true, selectedCount: 4))
}
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
func theCollaboratorsAreComparedByIdentity() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let other = try BoardStore(rootURL: fixture.root)
let session = DragSession()
let registry = LaneDropRegistry()
let bandSession = MarqueeSession()
let bandRegistry = MarqueeTargetRegistry()
let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
let drops = makeDrops(store: store, session: session, registry: registry)
let confirmations = TrashConfirmations()
let lane = makeRow(title: "Retired")
let base = makeView(store: store, lane: lane, confirmations: confirmations,
drops: drops, marquee: marquee)
#expect(base != makeView(store: other, lane: lane, confirmations: confirmations,
drops: drops, marquee: marquee))
// Window-lived state the row's permanent Delete goes through — identity is meaningful here
// for `CardFaceRole.isEquivalent(to:)`'s reason exactly.
#expect(base != makeView(store: store, lane: lane, confirmations: TrashConfirmations(),
drops: drops, marquee: marquee))
#expect(base != makeView(
store: store, lane: lane, confirmations: confirmations,
drops: makeDrops(store: store, session: DragSession(), registry: registry),
marquee: marquee
))
#expect(base != makeView(
store: store, lane: lane, confirmations: confirmations,
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
marquee: marquee
))
#expect(base != makeView(
store: store, lane: lane, confirmations: confirmations,
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
marquee: marquee
))
#expect(base != makeView(
store: store, lane: lane, confirmations: confirmations, drops: drops,
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store)
))
#expect(base != makeView(
store: store, lane: lane, confirmations: confirmations, drops: drops,
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store)
))
}
}