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 `` 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
616 lines
30 KiB
Swift
616 lines
30 KiB
Swift
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)
|
||
))
|
||
}
|
||
}
|