Files
lanework/KanbanTests/ViewEquatableTests.swift
T
rzen ce92c24190 Hero image for cards — one of the card's own attachments, banded across its face
A card whose `hero:` names one of its own attachments draws that picture as a
banner across the full width of its plate, above the icon-and-title row,
aspect-fill cropped into a fixed 2.75 em band — 36pt at the standard body, and
em-scaled like every other figure the board draws, so it grows with the system
text size and with the board's zoom rather than shrinking against a title twice
its usual size. The figure sits deliberately under the 44pt a plain one-line
card is tall: a hero card should read as a card with a picture on it rather than
a picture with a caption, which is 03's standing rule that the title dominates.

The key's grammar is a **bare filename**, and that is what separates it from the
board background's `image` subkey rather than a nervousness about paths. A board
names a file anywhere under its root, so a path is that key's reading and where
it leads is the renderer's question. A card names one of the files it already
owns — the flat `attachments/` folder the app lists, relocates into, and carries
through every move, copy, trash and restore — so `hero: art/sketch.png` is not an
awkward spelling of a hero image, it is a value the key cannot mean. It therefore
has no reading at all: a value carrying a separator, or spelling `.`/`..`, or
empty, is malformed at the document layer, which renders it as absent and leaves
the coerce tier's trace, exactly as `width: 1.5` does. The bytes stay as written,
the resolver re-checks containment anyway, and the whole degrade family below
that — a name pointing at a missing file, an unreadable one, or one that is not
an image — ends the same way: no banner, no defect, nothing written.

That last promise is about *height* as much as about ink, so the band is given no
height at all until a picture has actually decoded. A card whose hero cannot be
drawn lays out identically to a card with no key, structurally rather than by a
branch somebody has to remember; the price is one settle per hero as a board
opens, and none after that. Everything else the face draws is attached outside
the new stack and is untouched by it — the accent stripe still runs the plate's
full leading edge across the band's corner, the selection and file-hover strokes
still ring the whole plate, the cut and drag dims still cover it, and the drop
model still registers the plate's real height, so a hero card is simply a taller
card the masonry already understands. The trash draws it too, by the one-face
rule.

Decoding is ImageIO's downsampling path off the main actor at a quarter of the
backdrop's pixel budget (`BoardBackdrop.decode` gained the limit as a parameter
rather than being copied), and the results live in one app-wide, deliberately
non-observable cache keyed on path plus the file's date and size. Non-observable
because a tracked write there would invalidate every hero face on the board,
which is the O(board) invalidation this view was rebuilt once already to shed;
each face holds its own picture in view state and seeds it from the cache, which
is also what lets the drag replica — whose preview builder is non-escaping and
cannot await anything — carry the band at the face's real height. Taking a stamp
twice from one URL value turned out to answer with the first read's date and size
however many times the bytes had changed, so `stamp(of:)` now drops its cached
resource values first; noticing a replacement is the only thing a stamp is for.

The face takes the resolved URL as a compared input rather than resolving it, for
selected-ness's reason one axis over: resolving needs the card's folder, which a
face does not know, and finding it from the snapshot would be a board walk per
face. The lane and the trash column each know their own container and compute it
once for the whole strip.

There is no in-app setter this version — the key is written by hand or by an
agent, which is why the guide bumps to v13 with a clause spelling the grammar out
beside the other card keys, and why `attachments/` gets the one-line pointer an
agent that has just written `![](attachments/x.png)` will need. "Set as Hero"
from the attachment row is future work, as is the card window and print, which
draw the same model and show no banner today.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 23:41:15 -04:00

616 lines
30 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")))
}
@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)
))
}
}