From cb86316506a8fdd26698dbca6559e9c82fb9ff93 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 19:48:12 -0400 Subject: [PATCH] =?UTF-8?q?Selection=20liveness=20is=20effective=20?= =?UTF-8?q?=E2=80=94=20ancestor-walked=20(ratified)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel design session settled the tombstoned-ancestor question: a card counts as trashed if its own flag or its lane's says so, so tombstoning a lane ejects its cards from a live selection — they render nowhere once the lane collapses to a single trash entry, and nothing invisible may stay selected, drag-included, or pending-cut. One-line predicate change in Selection.resolved(against:), plus the test. Full BoardStore suite green (15 tests). Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/LiveStore/BoardStore.swift | 10 ++++++---- KanbanTests/BoardStoreTests.swift | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 7b72e4d..d7d50cf 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -100,9 +100,11 @@ public struct Selection: Sendable, Equatable { /// homogeneous-by-liveness invariant true across reloads so menu validation never sees a /// mixed selection. /// - /// The match is on the item's *own* tombstone flag. A live card sitting under a tombstoned lane - /// therefore survives re-resolution even though the board does not render it — the loader keeps - /// it in the snapshot, and "still present, same side" is the rule as written. + /// 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 selection 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. public func resolved(against snapshot: BoardModel) -> Selection { guard !ids.isEmpty else { return self } @@ -113,7 +115,7 @@ public struct Selection: Sendable, Equatable { survivors.insert(lane.id) } for card in lane.cards where ids.contains(card.id) { - if Liveness(isDeleted: card.isDeleted) == liveness { + if Liveness(isDeleted: lane.isDeleted || card.isDeleted) == liveness { survivors.insert(card.id) } } diff --git a/KanbanTests/BoardStoreTests.swift b/KanbanTests/BoardStoreTests.swift index 9ffa7f4..8d97fad 100644 --- a/KanbanTests/BoardStoreTests.swift +++ b/KanbanTests/BoardStoreTests.swift @@ -393,6 +393,25 @@ struct BoardStoreTests { #expect(store.selection.ids == [ItemID(rawValue: Ident.card2)]) } + @Test("Tombstoning a lane ejects its cards from a live selection — liveness is effective") + func selectionEjectsCardsUnderATombstonedLane() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) + + try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Lane one")) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + // The cards' own flags never changed, but their lane's did — and liveness is + // ancestor-walked (02, settled): the cards render nowhere once 03 collapses the lane to + // a single trash entry, and nothing invisible may stay selected. + let survivor = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 } + #expect(survivor?.isDeleted == false, "the card's own flag is untouched") + #expect(store.selection.ids.isEmpty) + } + @Test("A selection can resolve to nothing, and nothing is invented to replace it") func selectionCanResolveToNothing() async throws { let fixture = try makeBoard()