Proposal changes stop rebuilding the whole board — lanes and card faces gate on their values
LaneView and CardFaceView become Equatable and are instantiated through .equatable(): the strip's body re-runs on every drop-proposal change, and without the gates that rebuilt every lane and every card face on every cursor move of a drag. The == compares value inputs and window-lived collaborator identities; the closures BoardView rebuilds each pass are deliberately excluded (BoardDropContext.isEquivalent / MarqueeControl. isEquivalent / CardFaceRole.isEquivalent own that judgment). Observation reads inside the bodies still self-invalidate — the lane under the drag keeps re-running; the other lanes stop. Ports the pathfinder's CardView/ColumnView gating pattern (drag-perf suspect #2, card cbb6e476). Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The board strip's two rebuild gates — `LaneView.==` and `CardFaceView.==`, 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.
|
||||
///
|
||||
/// They do **not** pin the Observation half, and cannot: `store.selection`, `store.searchFilter`,
|
||||
/// `drops.session`'s proposal and the rest invalidate these views directly, and `.equatable()` has
|
||||
/// no say in that. 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 (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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
#expect(before == after)
|
||||
}
|
||||
|
||||
@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)
|
||||
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
|
||||
// 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)
|
||||
!= CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
}
|
||||
|
||||
@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)
|
||||
let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations),
|
||||
marquee: marquee, drops: drops)
|
||||
// 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))
|
||||
// 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))
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
#expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: DragSession(), registry: registry)
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry())
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry, gap: 20)
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store),
|
||||
drops: drops
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store),
|
||||
drops: drops
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user