Selected-ness rides down as a compared parameter — a marquee crossing repaints its faces, not the board
A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body read store.selection in three places — isSelected, the drag replica's count, and the context menu's styleTarget — and Observation invalidates every reader of the property, past the equatable gate entirely. The band overlay stayed cheap, which is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind. Now LaneView and TrashLaneView hoist one selection read per body and hand each face isSelected/selectedCount as compared parameters; StyleMenuItems takes its target as a deferred closure; TrashLaneRowView gains the same treatment plus the Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A growing band costs the selection's own running size; the real board's crossing fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a separate lane-level finding recorded in RENDER-INSTRUMENTATION.md. Also: select() gains defaultsSoleMember — the marquee's explicit nils never avoided the sole-member default, so a one-card band acquired a selectionHead and could scroll the lane out from under its own drag. MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
This commit is contained in:
@@ -3,9 +3,9 @@ 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`.
|
||||
/// 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
|
||||
@@ -15,11 +15,21 @@ import Testing
|
||||
/// 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 the board stops repainting — an edited card must make its lane a different value, and a
|
||||
/// newly selected card must make its face one.
|
||||
///
|
||||
/// 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.
|
||||
/// **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
|
||||
@@ -251,18 +261,58 @@ struct CardFaceViewEquatableTests {
|
||||
card: card,
|
||||
role: .board(openCard: { _ in }),
|
||||
marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry)
|
||||
drops: makeDrops(store: store, session: session, registry: registry),
|
||||
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)
|
||||
drops: makeDrops(store: store, session: session, registry: registry),
|
||||
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, 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, 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, isSelected: true, selectedCount: 1
|
||||
)
|
||||
#expect(alone != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 5
|
||||
))
|
||||
}
|
||||
|
||||
@Test("An edited card is unequal — the gate never withholds a repaint")
|
||||
func anEditedCardIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
@@ -281,18 +331,18 @@ struct CardFaceViewEquatableTests {
|
||||
#expect(before != after)
|
||||
|
||||
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
marquee: marquee, drops: drops, 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)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
!= CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1))
|
||||
}
|
||||
|
||||
@Test("The two homes are never equal, and the trash's confirmation host is compared by identity")
|
||||
@@ -310,19 +360,21 @@ struct CardFaceViewEquatableTests {
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
let board = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations),
|
||||
marquee: marquee, drops: drops)
|
||||
marquee: marquee, drops: drops, 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))
|
||||
marquee: marquee, drops: drops,
|
||||
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))
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
}
|
||||
|
||||
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
|
||||
@@ -339,31 +391,196 @@ struct CardFaceViewEquatableTests {
|
||||
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)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
|
||||
#expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
marquee: marquee, drops: drops,
|
||||
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)
|
||||
drops: makeDrops(store: store, session: DragSession(), registry: registry),
|
||||
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())
|
||||
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
|
||||
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)
|
||||
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
|
||||
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
|
||||
drops: drops, 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
|
||||
drops: drops, 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)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user