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), 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), 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() 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, isSelected: false, selectedCount: 1) != CardFaceView(store: store, card: after, role: .board(openCard: { _ in }), 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, isSelected: false, selectedCount: 1) != CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }), 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") 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, isSelected: false, selectedCount: 1) let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations), 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, 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, 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, isSelected: false, selectedCount: 1) #expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }), 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), 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()), 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), 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, 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, 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) )) } }