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.
551 lines
26 KiB
Swift
551 lines
26 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// `TransientBoardState` is one claim stated four ways: **transient state may only reference items
|
|
/// the current snapshot has**. So these tests are about what survives a reload and what doesn't —
|
|
/// per set, independently, and with the placeholder's lane-anchored variant of the same idea.
|
|
///
|
|
/// They drive a **real `BoardStore` over a real temp board**, exactly as `BoardStoreTests` does,
|
|
/// rather than calling `resolve(against:)` on a hand-built model: the container's contract includes
|
|
/// being called by the store's reload path, and a suite that never went through `land(_:...)` could
|
|
/// pass with that wire cut. The one exception is the same-rule test, which compares two *pure value*
|
|
/// functions and has to call one of them directly to have anything to compare.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
|
|
|
/// A lane or card whose `deleted:` key is present — the tombstone an agent or a hand-edit adds to a
|
|
/// file the user currently has selected, cut, or is creating a card under.
|
|
private func tombstoned(order: String, title: String) -> String {
|
|
"""
|
|
---
|
|
schema: 1
|
|
title: \(title)
|
|
order: \(order)
|
|
deleted: 2026-03-03T09:00:00Z
|
|
---
|
|
\(title) body.
|
|
|
|
"""
|
|
}
|
|
|
|
/// Two lanes and three cards — enough that "overlapping but different" member sets are expressible
|
|
/// and that a set can survive the reload that empties another.
|
|
@MainActor
|
|
private func makeBoard() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
|
return fixture
|
|
}
|
|
|
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
|
private let card1 = ItemID(rawValue: Ident.card1)
|
|
private let card2 = ItemID(rawValue: Ident.card2)
|
|
private let card3 = ItemID(rawValue: Ident.card3)
|
|
private let card4 = ItemID(rawValue: Ident.card4)
|
|
|
|
/// One foreign reload, start to settled — the only way anything in this suite gets re-resolved.
|
|
@MainActor
|
|
private func reload(_ store: BoardStore) async {
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
@Suite("TransientBoardState")
|
|
struct TransientBoardStateTests {
|
|
|
|
// MARK: The sets
|
|
|
|
@Test("A vanished item leaves only the sets that held it — each set resolves independently")
|
|
func setsResolveIndependently() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// Overlapping but different: card1 is selected and cut, card2 is selected and dragged,
|
|
// card3 is dragged and cut. Whatever happens to one member, two of the three sets are
|
|
// always the control.
|
|
store.transient.select([card1, card2], in: .board)
|
|
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], container: .board)
|
|
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], container: .board)
|
|
|
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
|
await reload(store)
|
|
|
|
#expect(store.transient.selection.ids == [card2], "the selection loses the member it held")
|
|
#expect(store.transient.pendingCut.ids == [card3], "so does the cut, on its own")
|
|
#expect(
|
|
store.transient.dragMembers.ids == [card2, card3],
|
|
"the drag never held card1 and must come through untouched — a member leaving one set may not disturb another"
|
|
)
|
|
#expect(store.selection == store.transient.selection, "the store's convenience is the same value")
|
|
}
|
|
|
|
@Test("The filter's universe and a reload's universe are the same rule, expressed once")
|
|
func constrainedAndResolvedAgree() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
let seeded = ItemReferenceSet(ids: [card1, card2], container: .board)
|
|
|
|
// Direction one — the live search filter (04-interactions.md § Search): card1's title and
|
|
// body both miss the query, so it is not in the visible set the predicate produced, and
|
|
// "hidden cards leave the selection" is just this intersection.
|
|
let visible: Set<ItemID> = [lane1, lane2, card2, card3]
|
|
let filtered = seeded.constrained(to: visible)
|
|
|
|
// Direction two — reload survival (02-architecture.md § Live-reload resilience): the same
|
|
// member, gone from the tree instead of hidden by a predicate.
|
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
|
await reload(store)
|
|
let resolved = seeded.resolved(against: store.snapshot)
|
|
|
|
#expect(filtered.ids == [card2])
|
|
#expect(filtered == resolved, "one primitive, two universes — the two rules are one rule")
|
|
}
|
|
|
|
@Test("A container crossing is a vanish for every item-referencing set")
|
|
func aContainerCrossingEjectsFromEverySet() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.select([card1, card2], in: .board)
|
|
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], container: .board)
|
|
|
|
// A foreign writer moves card1 and card2 into the board's trash — the move a delete is.
|
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
|
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
|
|
await reload(store)
|
|
|
|
// 02-architecture.md § Live-reload resilience, resettled 2026-07-28: "re-resolution matches
|
|
// UUID *and* container side … a foreign move that trashes a selected board card ejects it
|
|
// from the selection (and from the pending cut)".
|
|
let trashedIDs = Set(store.snapshot.trash.map(\.id))
|
|
#expect(trashedIDs == Set([card1, card2]))
|
|
#expect(store.transient.selection.ids.isEmpty)
|
|
#expect(store.transient.pendingCut.ids == [card3], "card3 never moved, so card3 stays cut")
|
|
}
|
|
|
|
@Test("A restore ejects a trash-side set the same way, in the other direction")
|
|
func restoringEjectsATrashSideSet() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// Everything that can point at an item, all aimed at the trashed card.
|
|
store.transient.select([card3], in: .trash, anchor: card3, head: card3)
|
|
store.transient.dragMembers = ItemReferenceSet(ids: [card3], container: .trash)
|
|
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .trash)
|
|
store.transient.beginRename(of: card3, currentTitle: "Third")
|
|
#expect(store.snapshot.trash.map(\.id) == [card3])
|
|
|
|
// An agent moves it back out — a container crossing the other way.
|
|
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
|
|
await reload(store)
|
|
|
|
#expect(store.snapshot.trash.isEmpty)
|
|
#expect(!ItemContainer.trash.ids(in: store.snapshot).contains(card3))
|
|
#expect(ItemContainer.board.ids(in: store.snapshot).contains(card3),
|
|
"presence is the whole test — the card is simply on the other side now")
|
|
|
|
#expect(store.transient.selection.ids.isEmpty)
|
|
#expect(store.transient.dragMembers.ids.isEmpty)
|
|
#expect(store.transient.pendingCut.ids.isEmpty)
|
|
|
|
// And no cursor survives on it: an anchor that ranges from a container the selection has left
|
|
// would be a range the user cannot see the origin of.
|
|
#expect(store.transient.selectionAnchor == nil)
|
|
#expect(store.transient.selectionHead == nil)
|
|
|
|
// The rename editor tracks the *board* container, so a card arriving back on the board keeps
|
|
// its editor — "a foreign move mid-rename is invisible" (04 ▸ Grammar). It is the departure
|
|
// into the trash that discards it, which the delete tests cover.
|
|
#expect(store.transient.renameEditor?.targetID == card3)
|
|
|
|
// Menu validation agrees with the sets, which is the point of both reading one rule.
|
|
let stale = ItemReferenceSet(ids: [card3], container: .trash)
|
|
#expect(!TrashModel.canDelete(selection: stale, in: store.snapshot))
|
|
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], container: .board),
|
|
in: store.snapshot))
|
|
}
|
|
|
|
@Test("Every set resolves to empty against a board whose lanes all vanished")
|
|
func everythingResolvesToNothingOnAnEmptyBoard() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.select([card1, lane1], in: .board)
|
|
store.transient.dragMembers = ItemReferenceSet(ids: [card2], container: .board)
|
|
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .board)
|
|
store.transient.beginPlaceholder(inLane: lane2)
|
|
|
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
|
await reload(store)
|
|
|
|
#expect(store.snapshot.lanes.isEmpty)
|
|
#expect(store.transient.selection == .empty)
|
|
#expect(store.transient.dragMembers == .empty)
|
|
#expect(store.transient.pendingCut == .empty)
|
|
#expect(store.transient.newCardPlaceholder == nil)
|
|
}
|
|
|
|
// MARK: The placeholder
|
|
|
|
@Test("A placeholder whose lane is deleted from the tree is discarded")
|
|
func placeholderDiscardedWhenItsLaneIsRemoved() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginPlaceholder(inLane: lane2)
|
|
store.transient.updateDraft("Half a title")
|
|
|
|
// Not a tombstone — the folder is gone, the way an agent's `rm -rf` or a Finder delete
|
|
// leaves it. Nothing was ever on disk for the placeholder, so there is nothing to clean up.
|
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
|
await reload(store)
|
|
|
|
#expect(store.snapshot.lanes.count == 1)
|
|
#expect(store.transient.newCardPlaceholder == nil, "the placeholder's lane vanished, so it goes with it")
|
|
}
|
|
|
|
@Test("A placeholder whose lane is tombstoned is discarded — a tombstoned lane renders nowhere")
|
|
func placeholderDiscardedWhenItsLaneIsTombstoned() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginPlaceholder(inLane: lane2)
|
|
store.transient.updateDraft("Half a title")
|
|
|
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
|
await reload(store)
|
|
|
|
// A lane delete is physical (03-board-ui.md § Trash), so "the placeholder's lane vanished in
|
|
// the reload" is literally the whole test.
|
|
#expect(store.snapshot.lanes.first { $0.id == lane2 } == nil)
|
|
#expect(store.transient.newCardPlaceholder == nil)
|
|
}
|
|
|
|
@Test("The placeholder hands off by discarding itself the moment its real card arrives")
|
|
func placeholderHandsOffWhenTheCardArrives() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginPlaceholder(inLane: lane2)
|
|
store.transient.updateDraft("Filed at last")
|
|
store.transient.commitPlaceholder(expecting: card4)
|
|
#expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(card4))
|
|
|
|
// What the Writer's create leaves behind, and what the watcher then round-trips: a real
|
|
// card folder under the anchor lane, carrying the minted id.
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed at last"))
|
|
await reload(store)
|
|
|
|
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.count == 2)
|
|
#expect(store.transient.newCardPlaceholder == nil, "the overlay's job ended when the card it stood in for landed")
|
|
}
|
|
|
|
@Test("The placeholder stands until its card actually arrives — a reload without it changes nothing")
|
|
func placeholderStandsUntilItsCardArrives() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginPlaceholder(inLane: lane2)
|
|
store.transient.updateDraft("Filed at last")
|
|
store.transient.commitPlaceholder(expecting: card4)
|
|
|
|
// A reload lands in the gap between the Writer's create and the watcher noticing it — the
|
|
// exact window the `.awaitingArrival` phase exists to cover. The lane is intact and the card
|
|
// is not there yet, so neither discard rule fires.
|
|
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
|
await reload(store)
|
|
|
|
#expect(store.snapshot.lanes.count == 3)
|
|
#expect(
|
|
store.transient.newCardPlaceholder
|
|
== NewCardPlaceholder(laneID: lane2, draftTitle: "Filed at last", phase: .awaitingArrival(card4))
|
|
)
|
|
}
|
|
|
|
@Test("An unrelated reload swaps the snapshot underneath the placeholder, draft intact")
|
|
func placeholderSurvivesAnUnrelatedReload() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginPlaceholder(inLane: lane1)
|
|
store.transient.updateDraft("Half a ti")
|
|
|
|
// An agent files a card in the other lane mid-typing. The overlay is not a card and is not
|
|
// in the snapshot, so it has nothing to lose here — including the keystrokes, which live
|
|
// nowhere else.
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent"))
|
|
await reload(store)
|
|
|
|
#expect(
|
|
store.transient.newCardPlaceholder
|
|
== NewCardPlaceholder(laneID: lane1, draftTitle: "Half a ti", phase: .editing)
|
|
)
|
|
}
|
|
|
|
// MARK: The rename editor
|
|
|
|
@Test("The rename editor seeds from the current title and records what is typed")
|
|
func renameEditorSeedsAndTracks() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
|
#expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "First"))
|
|
#expect(store.transient.isEditingInline)
|
|
|
|
store.transient.updateRenameDraft("First, renamed")
|
|
#expect(store.transient.renameEditor?.draftTitle == "First, renamed")
|
|
|
|
// An untitled item seeds *empty*, never with the word the face renders: "Untitled" is a
|
|
// rendering, not a value (03-board-ui.md § Card face), and typing it into the file would
|
|
// turn a missing key into a real title.
|
|
store.transient.beginRename(of: lane2, currentTitle: nil)
|
|
#expect(store.transient.renameEditor?.draftTitle.isEmpty == true)
|
|
|
|
store.transient.discardRename()
|
|
#expect(store.transient.renameEditor == nil)
|
|
#expect(!store.transient.isEditingInline)
|
|
}
|
|
|
|
@Test("One focus, one editor — beginning either kind ends the other")
|
|
func theTwoEditorsAreMutuallyExclusive() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginPlaceholder(inLane: lane1)
|
|
store.transient.updateDraft("half typed")
|
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
|
|
|
// The draft is discarded per the placeholder's own click-away rule — 02-architecture.md's
|
|
// "starting a new creation while a placeholder is open is a click-away for the draft",
|
|
// read in the other direction.
|
|
#expect(store.transient.newCardPlaceholder == nil)
|
|
#expect(store.transient.renameEditor?.targetID == card1)
|
|
|
|
store.transient.beginPlaceholder(inLane: lane2)
|
|
#expect(store.transient.renameEditor == nil)
|
|
#expect(store.transient.newCardPlaceholder?.laneID == lane2)
|
|
}
|
|
|
|
@Test("A rename whose target is deleted from the tree is discarded")
|
|
func renameDiscardedWhenItsTargetIsRemoved() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
|
store.transient.updateRenameDraft("Never lands")
|
|
|
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
|
await reload(store)
|
|
|
|
// "A target that is tombstoned, deleted, or gone at commit time discards the editor and its
|
|
// keystrokes silently" (04-interactions.md ▸ Grammar).
|
|
#expect(store.transient.renameEditor == nil)
|
|
}
|
|
|
|
@Test("A rename whose target enters the trash is discarded — a container crossing is a vanish")
|
|
func renameDiscardedWhenItsTargetIsTrashed() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
|
|
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
|
await reload(store)
|
|
|
|
// 04 ▸ Grammar: "a target that is trashed, deleted, or gone at commit time discards the
|
|
// editor and its keystrokes silently — entering the trash is a vanish from the board".
|
|
#expect(store.snapshot.trash.map(\.id) == [card1])
|
|
#expect(store.transient.renameEditor == nil)
|
|
}
|
|
|
|
@Test("A rename whose lane is deleted is discarded too — the card went with it")
|
|
func renameDiscardedWhenItsLaneIsDeleted() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
|
|
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
|
await reload(store)
|
|
|
|
// A lane delete is physical, so the card is simply not in the snapshot — no ancestor walk.
|
|
#expect(store.snapshot.lanes.first { $0.id == lane1 } == nil)
|
|
#expect(store.transient.renameEditor == nil)
|
|
}
|
|
|
|
@Test("A rename survives a foreign move — the editor follows the UUID, not the position")
|
|
func renameSurvivesAForeignMove() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
|
store.transient.updateRenameDraft("Still mine")
|
|
|
|
// An agent files the card into the other lane mid-typing. "A foreign *move* mid-rename is
|
|
// invisible — the editor follows the UUID and the commit writes the title wherever the card
|
|
// now lives."
|
|
try FileManager.default.moveItem(
|
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
|
to: fixture.url("\(Ident.lane2)/\(Ident.card1)")
|
|
)
|
|
await reload(store)
|
|
|
|
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.contains { $0.id == card1 } == true)
|
|
#expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "Still mine"))
|
|
}
|
|
|
|
@Test("A rename of a lane survives an unrelated reload, draft intact")
|
|
func laneRenameSurvivesAnUnrelatedReload() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginRename(of: lane1, currentTitle: "Todo")
|
|
store.transient.updateRenameDraft("To d")
|
|
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent"))
|
|
await reload(store)
|
|
|
|
#expect(store.transient.renameEditor == RenameEditor(targetID: lane1, draftTitle: "To d"))
|
|
}
|
|
|
|
// MARK: The cursors' sole-member default
|
|
|
|
@Test("The sole-member default is opt-out — a band that sweeps one card acquires no cursors")
|
|
func theSoleMemberDefaultIsOptOut() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// **The default, unchanged**: a one-item selection made by any ordinary route is a legitimate
|
|
// range origin and a legitimate place to arrow from, so `nil` takes the sole member.
|
|
store.transient.select([card1], in: .board)
|
|
#expect(store.transient.selectionAnchor == card1)
|
|
#expect(store.transient.selectionHead == card1)
|
|
|
|
// **The opt-out**: a rubber band names no click to range from and no item to arrow from
|
|
// whatever it happens to enclose, and passing `nil` cannot say so — `nil` is what *asks* for
|
|
// the default. `MarqueeControl.gesture` passes the flag, and this is what it buys: a band
|
|
// narrowed onto exactly one card leaves both cursors empty, so a ⇧-click after it acts plain
|
|
// and `LaneView.cardStack`'s scroll-to has no head to fire on mid-drag.
|
|
store.transient.select([card2], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
|
|
#expect(store.transient.selection.ids == [card2], "the selection itself still lands")
|
|
#expect(store.transient.selectionAnchor == nil)
|
|
#expect(store.transient.selectionHead == nil)
|
|
|
|
// The flag defaults nothing away: an explicit cursor is still taken verbatim.
|
|
store.transient.select([card2], in: .board, anchor: card2, head: card2, defaultsSoleMember: false)
|
|
#expect(store.transient.selectionAnchor == card2)
|
|
#expect(store.transient.selectionHead == card2)
|
|
|
|
// And the store's funnel forwards it — the marquee calls `BoardStore.select`, not the
|
|
// transient's directly.
|
|
store.select([card1], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
|
|
#expect(store.transient.selectionAnchor == nil)
|
|
#expect(store.transient.selectionHead == nil)
|
|
}
|
|
|
|
// MARK: The last-active lane
|
|
|
|
@Test("Selecting a lane or one of its cards marks it active; clearing the selection does not forget it")
|
|
func selectionMarksTheActiveLane() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
#expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember")
|
|
|
|
store.select([lane2], in: .board)
|
|
#expect(store.transient.lastActiveLaneID == lane2)
|
|
|
|
// A *card* selection is its lane holding selection too — 04's "the lane that most recently
|
|
// held selection or a creation".
|
|
store.select([card1], in: .board)
|
|
#expect(store.transient.lastActiveLaneID == lane1)
|
|
|
|
// A cross-lane selection names no single lane, so it leaves the memory alone rather than
|
|
// guessing at one of the two.
|
|
store.select([card1, card3], in: .board)
|
|
#expect(store.transient.lastActiveLaneID == lane1)
|
|
|
|
// Deselecting does not un-happen where the user was working: ⌘N with nothing selected is
|
|
// exactly the case the memory exists to answer.
|
|
store.clearSelection()
|
|
#expect(store.transient.lastActiveLaneID == lane1)
|
|
}
|
|
|
|
@Test("Creating into a lane marks it active, and a vanished lane is forgotten on reload")
|
|
func creationMarksTheActiveLaneAndAVanishClearsIt() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.beginPlaceholder(inLane: lane2)
|
|
#expect(store.transient.lastActiveLaneID == lane2)
|
|
|
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
|
await reload(store)
|
|
|
|
// A lane that is gone is no target at all; `NewCardTarget` then falls through to the first
|
|
// lane rather than proposing the trash.
|
|
#expect(store.transient.lastActiveLaneID == nil)
|
|
}
|
|
|
|
// MARK: Per-open values
|
|
|
|
@Test("Trash visibility and the search query default per-open and pass through a reload untouched")
|
|
func perOpenValuesDefaultAndSurviveResolve() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// Fresh container, fresh store — the trash is hidden on every open and there is no query.
|
|
// Nothing resets these; being built with the store is the reset.
|
|
#expect(store.transient.isTrashVisible == false)
|
|
#expect(store.transient.searchQuery.isEmpty)
|
|
|
|
store.transient.isTrashVisible = true
|
|
store.transient.searchQuery = "log"
|
|
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fix login"))
|
|
await reload(store)
|
|
|
|
// Neither references an item, so no snapshot can invalidate either — and the query's
|
|
// *results* are recomputed rather than stored, which is why there is nothing else to check.
|
|
#expect(store.transient.isTrashVisible)
|
|
#expect(store.transient.searchQuery == "log")
|
|
}
|
|
}
|