02 rules the trashed side has exactly one definition: the set with trash rows — a card carrying its own deleted: under a tombstoned lane is in neither universe, so an anchor or selection can never survive on an item that renders nowhere. The membership rule now lives once, in Liveness.walk, and ItemReferenceSet.idUniverse, TrashModel.entries, paths, and emptyTrashTargets all derive from it — the old lane-OR-card logic that admitted subsumed cards to the trashed side is gone, and the two universes deliberately no longer partition the board. Put Back, Delete Immediately, and Empty Trash outcomes are unchanged: a tombstoned lane still moves and purges whole, its nested tombstones with it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
521 lines
24 KiB
Swift
521 lines
24 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], liveness: .live)
|
|
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], liveness: .live)
|
|
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], liveness: .live)
|
|
|
|
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], liveness: .live)
|
|
|
|
// 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("Tombstoning a lane ejects its cards from every referencing set — liveness is effective")
|
|
func effectiveLivenessEjectsFromEverySet() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.transient.select([card1, card2], liveness: .live)
|
|
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], liveness: .live)
|
|
|
|
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
|
await reload(store)
|
|
|
|
// The cards' own flags never changed, but their lane's did — and liveness is ancestor-walked
|
|
// (02, settled): they render nowhere once 03 collapses the lane to a single trash entry, and
|
|
// nothing invisible may stay selected or pending-cut.
|
|
let survivor = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
|
|
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
|
|
#expect(store.transient.selection.ids.isEmpty)
|
|
#expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut")
|
|
}
|
|
|
|
@Test("A card with its own deleted: under a tombstoned lane is in neither universe")
|
|
func ownFlaggedCardUnderATombstonedLaneIsInNeitherUniverse() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// Everything that can point at an item, all aimed at card3 — and on the *trashed* side, as
|
|
// if the user had clicked its trash row a moment before its lane went too.
|
|
store.transient.select([card3], liveness: .trashed, anchor: card3, head: card3)
|
|
store.transient.dragMembers = ItemReferenceSet(ids: [card3], liveness: .trashed)
|
|
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .trashed)
|
|
store.transient.beginRename(of: card3, currentTitle: "Third")
|
|
|
|
// Both flags at once: the card carries its own `deleted:` *and* an agent tombstones its lane.
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", tombstoned(order: "1024", title: "Third"))
|
|
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
|
await reload(store)
|
|
|
|
let lane = try #require(store.snapshot.lanes.first { $0.id == lane2 })
|
|
#expect(lane.isDeleted)
|
|
#expect(lane.cards.first { $0.id == card3 }?.isDeleted == true, "the card's own flag is on disk")
|
|
|
|
// The lane's single entry subsumes it (03-board-ui.md's absolute ancestor walk), so it has
|
|
// no row — and "the trashed side has exactly one definition: the set with trash rows"
|
|
// (02-architecture.md, settled). No row, no membership, on either side.
|
|
#expect(!TrashModel.entries(of: store.snapshot).map(\.id).contains(card3))
|
|
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .trashed).contains(card3))
|
|
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .live).contains(card3))
|
|
|
|
// So every set holding it is ejected — from the trashed side here, and from the live side
|
|
// for the same reason, which the value function says directly since a set has one side.
|
|
#expect(store.transient.selection.ids.isEmpty)
|
|
#expect(store.transient.dragMembers.ids.isEmpty)
|
|
#expect(store.transient.pendingCut.ids.isEmpty)
|
|
#expect(ItemReferenceSet(ids: [card3], liveness: .live).resolved(against: store.snapshot).isEmpty)
|
|
|
|
// And no cursor or editor survives on it: an anchor that ranges from somewhere the board
|
|
// draws nowhere would be a range the user cannot see the origin of.
|
|
#expect(store.transient.selectionAnchor == nil)
|
|
#expect(store.transient.selectionHead == nil)
|
|
#expect(store.transient.renameEditor == nil)
|
|
|
|
// Menu validation agrees, which is the point of the sets and the commands reading one rule:
|
|
// neither ⌘⌫ twin offers to act on it.
|
|
let stale = ItemReferenceSet(ids: [card3], liveness: .trashed)
|
|
#expect(!TrashModel.canActOnTrash(selection: stale, in: store.snapshot))
|
|
#expect(!TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], liveness: .live),
|
|
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], liveness: .live)
|
|
store.transient.dragMembers = ItemReferenceSet(ids: [card2], liveness: .live)
|
|
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .live)
|
|
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 fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
|
await reload(store)
|
|
|
|
// Still in the snapshot — the trash renders its single entry — but the lane the editor was
|
|
// sitting in is not on the board any more, which is the same vanish as far as an overlay
|
|
// anchored to it is concerned.
|
|
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.isDeleted == true)
|
|
#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 is tombstoned is discarded — a liveness flip is a vanish")
|
|
func renameDiscardedWhenItsTargetIsTombstoned() 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.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
|
await reload(store)
|
|
|
|
#expect(store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }?.isDeleted == true)
|
|
#expect(store.transient.renameEditor == nil)
|
|
}
|
|
|
|
@Test("A rename under a tombstoned lane is discarded too — liveness is effective")
|
|
func renameDiscardedWhenItsLaneIsTombstoned() 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.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
|
await reload(store)
|
|
|
|
// The card's own flag never changed; its lane's did. The ancestor walk is absolute — the
|
|
// card renders nowhere, so the editor sitting on it has no target
|
|
// (`CardWindowHost.cardWindowFate`'s rule, applied to the third inline editor).
|
|
let card = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
|
|
#expect(card?.isDeleted == false)
|
|
#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 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], liveness: .live)
|
|
#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], liveness: .live)
|
|
#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], liveness: .live)
|
|
#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 fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
|
await reload(store)
|
|
|
|
// A lane that renders nowhere 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")
|
|
}
|
|
}
|