diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 7ff924d..5b05719 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1936,10 +1936,10 @@ public final class BoardStore { /// carrying their own `deleted:` stay tombstoned — and their rows reappear in the trash, which /// is exactly the two-step recovery the design settled on. /// - /// A card whose lane is itself tombstoned cannot be reached this way through the UI (it has no - /// row — `TrashModel.entries`), but the path resolution admits it, and the outcome is the pure - /// view's honest one: the key is removed, and the card still renders nowhere because its lane - /// is still tombstoned. Putting the lane back then shows it. + /// A card whose lane is itself tombstoned is **not reachable here at all**, by construction + /// rather than by the UI happening not to offer it: it has no trash row, and the trashed side of + /// `TrashModel.paths` is that row set exactly (`Liveness.walk`). Recovering it stays the two-step + /// the design settled on — put the lane back, then put the card back from the row it regains. /// /// The selection is deliberately left alone: the restored items flip liveness, and the reload's /// resolve rule ejects them from a `.trashed` set as a vanish — the same silent shrink an diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index 5a85a0d..5b22e0c 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -26,6 +26,54 @@ public enum Liveness: String, Codable, Sendable, Equatable { } } +// MARK: - The one definition of a side + +extension Liveness { + + /// Every item `snapshot` has on this side, visited in **board order** — lanes left to right, + /// each lane immediately before its own cards — as the lane it lives under and, for a card, the + /// card itself. + /// + /// **This is the definition, and it is the only one** (02-architecture.md § Changes from Kanban, + /// settled: "universe and rows are one function, never a broader set with a pointer-side + /// subset"). Everything that asks what is on a side is this walk with a different accumulator: + /// `ItemReferenceSet.idUniverse` collects ids, `TrashModel.entries` builds the trash's rows, + /// `TrashModel.paths` and `emptyTrashTargets` build folders. Two spellings of the rule would be + /// two things to keep in step, and the one they would eventually disagree about is precisely the + /// item below. + /// + /// **The whole rule is the `continue`: a tombstoned lane subsumes its subtree.** It contributes + /// one item to the trashed side — its own row — and its cards contribute nothing to *either* + /// side, whatever their own flags say. That is 03-board-ui.md § Trash's absolute ancestor walk + /// stated as code: "a card that carries its own `deleted:` under a tombstoned lane has **no row + /// of its own**". + /// + /// **So the two sides do not partition the board, and that is the point.** A card beneath a + /// tombstoned lane is in *neither* universe, because it renders nowhere — no row, no membership. + /// A selection, a drag, a pending cut, a range anchor or a navigation head can therefore never + /// survive a reload sitting on something no surface would draw, and 04-interactions.md § Search's + /// hidden-cards-leave-the-selection rule and 02's reload-survival rule stay one rule rather than + /// two that happen to agree. + /// + /// Non-escaping and accumulator-driven rather than array-returning: the callers below run on + /// every reload and on every menu validation, and none of them wants a board-sized copy of the + /// model to throw away. + func walk(_ snapshot: BoardModel, visiting visit: (Lane, Card?) -> Void) { + for lane in snapshot.lanes { + if lane.isDeleted { + // The subsumption, both halves of it: the lane is a trash row, and its cards are + // nobody's — so the loop below never runs for them. + if self == .trashed { visit(lane, nil) } + continue + } + if self == .live { visit(lane, nil) } + for card in lane.cards where Liveness(isDeleted: card.isDeleted) == self { + visit(lane, card) + } + } + } +} + // MARK: - ItemReferenceSet /// A set of UUIDs over the snapshot plus the liveness side it lives on — **the one shape every @@ -98,32 +146,27 @@ public struct ItemReferenceSet: Sendable, Equatable { /// item that is tombstoned or vanishes externally before paste drops out of the pending /// cut"), and so does drag membership (04 ▸ Drag and drop's emptied-drag rule). /// - /// The liveness that is matched is **effective — ancestor-walked** (settled): a card counts as - /// trashed if its own flag *or its lane's* says so. Tombstoning a lane therefore ejects its - /// cards from a live set even though their own flags never changed — the card renders nowhere - /// once 03-board-ui.md collapses the lane to a single trash entry, and nothing invisible may - /// stay selected, drag-included, or pending-cut. + /// The liveness that is matched is **effective — ancestor-walked** (settled), and the trashed + /// side is exactly the trash's rows: `Liveness.walk` is the one definition both sides read. + /// Tombstoning a lane therefore ejects its cards from a live set even though their own flags + /// never changed — and does **not** hand them to a trashed set, because the lane's single entry + /// subsumes them (03-board-ui.md). A card under a tombstoned lane renders nowhere on either + /// side, and nothing invisible may stay selected, drag-included, or pending-cut. public func resolved(against snapshot: BoardModel) -> ItemReferenceSet { guard !ids.isEmpty else { return self } return constrained(to: Self.idUniverse(of: snapshot, on: liveness)) } - /// Every id in `snapshot` whose **effective** liveness is `side` — the reload direction's - /// universe, and the only place the ancestor walk lives. + /// Every id in `snapshot` on `side` — the reload direction's universe. /// - /// A lane contributes itself on the side its own flag names, and each of its cards on the side - /// `lane.isDeleted || card.isDeleted` names — the walk being one level deep is the whole of it, - /// because the tree is (01-storage-format.md § Fractal layout: board → lane → card). + /// One line over `Liveness.walk`, which is where the rule itself lives and is stated: the live + /// side is the live lanes and their unflagged cards, and the trashed side is *exactly* the trash's + /// rows — tombstoned lanes, plus cards carrying their own `deleted:` under a live lane. Nothing + /// else is in either, so a card hidden beneath a tombstoned lane belongs to no universe and no + /// set may go on referencing it. static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set { var universe: Set = [] - for lane in snapshot.lanes { - if Liveness(isDeleted: lane.isDeleted) == side { - universe.insert(lane.id) - } - for card in lane.cards where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side { - universe.insert(card.id) - } - } + side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.id) } return universe } } diff --git a/Kanban/LiveStore/TrashModel.swift b/Kanban/LiveStore/TrashModel.swift index a286f2d..fb487d6 100644 --- a/Kanban/LiveStore/TrashModel.swift +++ b/Kanban/LiveStore/TrashModel.swift @@ -77,7 +77,11 @@ public enum TrashEntry: Identifiable, Sendable, Equatable { /// /// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it — "a /// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There -/// is no trash carve-out from 01-storage-format.md's consumer rule. +/// is no trash carve-out from 01-storage-format.md's consumer rule. This rule is not spelled +/// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is +/// held to *is* this row set — "universe and rows are one function" (02-architecture.md § Changes +/// from Kanban, settled). Everything below that asks what is in the trash asks that walk, so the +/// rows a user sees and the ids a selection may hold cannot drift apart. /// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board — /// cards without their own flag; individually tombstoned descendants aren't in that number, since /// they come back to the *trash*". @@ -121,13 +125,23 @@ public enum TrashModel { /// The trash's rows, in the order the quasi-lane shows them. /// - /// The walk is one level deep because the tree is (01-storage-format.md § Fractal layout), and - /// the branch on `lane.isDeleted` *is* the ancestor walk: a tombstoned lane contributes exactly - /// one entry and its cards contribute none, whatever their own flags say. + /// **Which items are rows is not decided here** — it is `Liveness.trashed.walk`, the same walk + /// `ItemReferenceSet.idUniverse(of:on:)` reads, because the trashed universe and this row set are + /// one function. Its `continue` on a tombstoned lane is the absolute ancestor walk: such a lane + /// contributes exactly one entry and its cards contribute none, whatever their own flags say. + /// + /// What is left for this function is what a row *carries* — the returning count and the sort + /// inputs — and the order it shows in, neither of which any other caller of the walk wants. public static func entries(of snapshot: BoardModel) -> [TrashEntry] { var rows: [Row] = [] - for lane in snapshot.lanes { - if lane.isDeleted { + Liveness.trashed.walk(snapshot) { lane, card in + if let card { + rows.append(Row( + entry: .card(card, laneID: lane.id), + deleted: card.deleted.value, + name: card.id.rawValue + )) + } else { // Rule 2: only the cards *without* their own flag come back with the lane. The ones // that carry a flag stay tombstoned and get their rows back in the trash — which is // why Put Back on such a card is deliberately two steps. @@ -137,14 +151,6 @@ public enum TrashModel { deleted: lane.deleted.value, name: lane.id.rawValue )) - continue - } - for card in lane.cards where card.isDeleted { - rows.append(Row( - entry: .card(card, laneID: lane.id), - deleted: card.deleted.value, - name: card.id.rawValue - )) } } return rows.sorted(by: isOrdered).map(\.entry) @@ -153,7 +159,11 @@ public enum TrashModel { /// Whether the board has anything in its trash at all — the "non-empty" half of Empty Trash…'s /// menu validation, which "reads the board's tombstones, not the filtered view" (03 ▸ Trash). /// - /// Cheaper than building the entries and used where only the answer matters. + /// The walk's non-emptiness in **short-circuit form**, which is the one place the rule is + /// restated and only because stopping early is the whole point: a tombstoned lane is a row + /// outright, and under a live lane any own-flagged card is one. There is deliberately no third + /// clause for a card beneath a tombstoned lane — the lane has already answered `true` for it. + /// `TrashModelTests` pins the equivalence to `entries(of:).isEmpty` so the shortcut cannot drift. public static func isEmpty(_ snapshot: BoardModel) -> Bool { !snapshot.lanes.contains { lane in lane.isDeleted || lane.cards.contains(where: \.isDeleted) @@ -193,27 +203,31 @@ public enum TrashModel { // MARK: - Paths for the trash's writes - /// The folders `ids` names, in display order, restricted to one **effective** liveness side. + /// The folders `ids` names, in display order, restricted to one liveness side. /// - /// The universe rule is `ItemReferenceSet.idUniverse`'s, spelled here in terms of paths rather - /// than ids because a write needs to know *where* the item is: a lane contributes itself on the - /// side its own flag names, and a card on the side `lane.isDeleted || card.isDeleted` names. + /// **The membership rule is not restated here** — it is `Liveness.walk`'s, the same one + /// `ItemReferenceSet.idUniverse` and `entries(of:)` read — spelled in *paths* rather than ids + /// because a write needs to know where the item is. So the trashed side is the trash's rows and + /// nothing besides: a card beneath a tombstoned lane is not individually addressable, which costs + /// nothing (it has no row for a user to act on, so no command can name it) and buys the guarantee + /// that every path this hands a writer names something the board would draw. + /// + /// **A tombstoned lane still takes its subtree with it**, and that is subsumption rather than + /// omission: its path is the lane *folder*, and removing a folder removes what is inside it. Put + /// Back on it restores the lane and every card that rode along; Delete Immediately on it purges + /// the whole tree, own-flag cards included — the outcome the lane entry's confirmation sentence + /// exists to warn about (`message(lanes:unrecoverable:)`). /// /// Display order — lanes left to right, each lane then its cards — rather than the caller's set /// iteration order, which is not an order at all: a batch that fails partway must fail the same - /// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason). + /// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason). The walk + /// visits in exactly that order, so this is a filter over it and never a sort. public static func paths(of ids: Set, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] { guard !ids.isEmpty else { return [] } var result: [ItemPath] = [] - for lane in snapshot.lanes { - let laneSide = Liveness(isDeleted: lane.isDeleted) - if laneSide == side, ids.contains(lane.id) { - result.append(ItemPath(laneID: lane.id, cardID: nil)) - } - for card in lane.cards - where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side && ids.contains(card.id) { - result.append(ItemPath(laneID: lane.id, cardID: card.id)) - } + side.walk(snapshot) { lane, card in + guard ids.contains(card?.id ?? lane.id) else { return } + result.append(ItemPath(laneID: lane.id, cardID: card?.id)) } return result } @@ -221,20 +235,19 @@ public enum TrashModel { /// Every folder Empty Trash removes — "emptying purges every tombstone on the board, filter or /// no filter" (03 ▸ Trash). /// + /// `paths(of:on:in:)` on the trashed side with **no id filter at all**, which is the strongest + /// form of that guarantee: the command's scope is the trashed universe itself, so it cannot + /// narrow to a selection any more than it can narrow to the search. + /// /// **A tombstoned lane contributes only itself**, and that is not an omission: removing the lane /// folder removes the tree beneath it, own-flag cards included. Listing those cards as well would /// be redundant purges of paths the first removal already took (harmless — `purgeItem` treats a - /// folder that is already gone as success — but noise). + /// folder that is already gone as success — but noise) and would require a second, broader + /// definition of "in the trash" than the one every other caller reads. public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] { var result: [ItemPath] = [] - for lane in snapshot.lanes { - if lane.isDeleted { - result.append(ItemPath(laneID: lane.id, cardID: nil)) - continue - } - for card in lane.cards where card.isDeleted { - result.append(ItemPath(laneID: lane.id, cardID: card.id)) - } + Liveness.trashed.walk(snapshot) { lane, card in + result.append(ItemPath(laneID: lane.id, cardID: card?.id)) } return result } diff --git a/KanbanTests/TransientBoardStateTests.swift b/KanbanTests/TransientBoardStateTests.swift index 73a4dc6..8ef2acd 100644 --- a/KanbanTests/TransientBoardStateTests.swift +++ b/KanbanTests/TransientBoardStateTests.swift @@ -137,6 +137,56 @@ struct TransientBoardStateTests { #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() diff --git a/KanbanTests/TrashModelTests.swift b/KanbanTests/TrashModelTests.swift index 9a46673..ac357b1 100644 --- a/KanbanTests/TrashModelTests.swift +++ b/KanbanTests/TrashModelTests.swift @@ -213,7 +213,7 @@ struct TrashModelContentsTests { #expect(!TrashModel.isEmpty(try load(dirty))) } - @Test("Paths resolve on the effective liveness side, in display order") + @Test("Paths resolve on the row set's liveness side, in display order") func pathsResolveByEffectiveLiveness() throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -224,22 +224,64 @@ struct TrashModelContentsTests { ] // Live: the live lane and its live card. `card3` is live by its own flag but its lane is - // tombstoned, so the ancestor walk puts it on the other side. + // tombstoned, so the ancestor walk takes it off this side. let liveSide = TrashModel.paths(of: everything, on: .live, in: model) #expect(liveSide.map { ($0.laneID.rawValue, $0.cardID?.rawValue) }.map { "\($0.0)/\($0.1 ?? "-")" } == ["\(Ident.lane1)/-", "\(Ident.lane1)/\(Ident.card2)"]) - // Trashed: the tombstoned card, the tombstoned lane, and everything the lane hides — in - // display order, lanes left to right and each lane before its cards. + // Trashed: the tombstoned card and the tombstoned lane — the trash's two rows, in display + // order, lanes left to right and each lane before its cards. Nothing under `lane2` is here: + // its cards have no rows, and the lane's *folder* is what takes them (Put Back returns them + // with it; Delete Immediately purges them with it). let trashedSide = TrashModel.paths(of: everything, on: .trashed, in: model) #expect(trashedSide.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" } - == ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-", - "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(More.cardD)"]) + == ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"]) #expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty) #expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty) } + @Test("The trashed universe is exactly the trash's rows — one function, not two") + func trashedUniverseIsTheRowSet() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + // The guarantee 02-architecture.md settles: "universe and rows are one function, never a + // broader set with a pointer-side subset". Anything a set may reference on the trashed side + // is something the quasi-lane draws. + let rows = Set(TrashModel.entries(of: model).map(\.id)) + let trashed = ItemReferenceSet.idUniverse(of: model, on: .trashed) + #expect(trashed == rows) + + // And the two universes therefore do **not** partition the board. `cardD` carries its own + // `deleted:` under a tombstoned lane and `card3` rides along unflagged under the same one; + // both render nowhere, so neither is on either side. + let liveUniverse = ItemReferenceSet.idUniverse(of: model, on: .live) + for hidden in [ItemID(rawValue: More.cardD), ItemID(rawValue: Ident.card3)] { + #expect(!rows.contains(hidden)) + #expect(!trashed.contains(hidden)) + #expect(!liveUniverse.contains(hidden)) + #expect(ItemReferenceSet(ids: [hidden], liveness: .trashed).resolved(against: model).isEmpty) + #expect(ItemReferenceSet(ids: [hidden], liveness: .live).resolved(against: model).isEmpty) + } + + // Paths are the same walk in folder form, so they name the same things — one per row. + #expect(TrashModel.paths(of: rows, on: .trashed, in: model).count == rows.count) + #expect(TrashModel.emptyTrashTargets(in: model).count == rows.count) + // And `isEmpty`'s short-circuit is that walk's non-emptiness, on both a dirty board and a + // clean one. + #expect(TrashModel.isEmpty(model) == rows.isEmpty) + + let clean = try WriterFixture() + defer { clean.tearDown() } + try clean.item("", Item.board) + try clean.item(Ident.lane1, live(order: "1024", title: "Todo")) + let cleanModel = try load(clean) + #expect(TrashModel.isEmpty(cleanModel) == TrashModel.entries(of: cleanModel).isEmpty) + #expect(ItemReferenceSet.idUniverse(of: cleanModel, on: .trashed).isEmpty) + } + @Test("Empty Trash targets every tombstone, a tombstoned lane contributing only its own folder") func emptyTrashTargets() throws { let fixture = try makeBoard()