Every comment-trash purge kneels to the ownership gate — the container-whole retirement retires
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -453,6 +453,203 @@ struct CommentResidueTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The purge's ownership gate
|
||||
|
||||
/// **Every purge of `comments/.trash/` is per-entry behind the ownership gate** (13-native-undo.md
|
||||
/// ▸ Interaction with the trash, ruled 2026-08-06):
|
||||
///
|
||||
/// > "a step's retirement and a no-step close remove only entries no live step still backs — the same
|
||||
/// > `backedContent` inventory the sweep consults, making it one condition, *three* consumers. The
|
||||
/// > container-whole purge assumed one owning step per card's comment trash, and two sessions over the
|
||||
/// > same card broke it: the second step's retirement — or a mere reopen-and-close that registered
|
||||
/// > nothing — emptied the first step's backing out from under it, silently killing an undo the stack
|
||||
/// > still promised."
|
||||
///
|
||||
/// The sweep's own half of that condition is `CommentResidueTests` above; the close-and-retirement
|
||||
/// arcs through a real card window are `CardSessionUndoTests`'. What is here is the gate itself, at
|
||||
/// the one method the ruling retired the container-whole behaviour from.
|
||||
@MainActor
|
||||
@Suite("Comments ▸ the purge's ownership gate")
|
||||
struct CommentPurgeGateTests {
|
||||
|
||||
private let cardID = ItemID(rawValue: Ident.card1)
|
||||
|
||||
/// One coarse close step's shape, as the fold registers it: a backing claim over one trashed
|
||||
/// comment, and a retirement that runs the deferred purge.
|
||||
private func coarseStep(
|
||||
holding commentID: String,
|
||||
purging store: BoardStore,
|
||||
inCard id: ItemID
|
||||
) -> HistoryStep {
|
||||
HistoryStep(
|
||||
name: "Changes to '\(cardTitle)'",
|
||||
backing: [.trashedComment(ItemID(rawValue: commentID), inCard: id)],
|
||||
retirement: HistoryStep.Retirement { store.purgeCommentTrash(inCard: id) },
|
||||
undo: { _ in .applied },
|
||||
redo: { _ in .applied }
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A purge removes what no step names and spares what one does")
|
||||
func thePurgeIsPartitionedByOwnership() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
||||
// Seeded rather than deleted: a previous session's leftover, which nothing on this stack names.
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
// The stack is bound, not discarded: `BoardStore.history` is weak, so a provider nobody holds
|
||||
// is a board with no backing at all.
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
|
||||
#expect(history.canUndo)
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "a live step backs this one")
|
||||
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "and nothing backs this one")
|
||||
}
|
||||
|
||||
@Test("A retirement's purge leaves another live step's backing standing")
|
||||
func aRetirementSparesTheOtherStepsBacking() throws {
|
||||
// The defect the ruling names, in the shape it takes on disk: two sessions over one card, each
|
||||
// leaving a coarse step holding its own entry. The first step retiring must not empty the
|
||||
// second's backing out from under it.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
let first = coarseStep(holding: CommentIdent.one, purging: store, inCard: cardID)
|
||||
history.register(first)
|
||||
// Undone-and-superseded is the clean exit the ruling names, and the only one that retires
|
||||
// exactly one step: the undo puts `first` on the redo stack, and registering the second
|
||||
// session's step clears it — retiring `first` while `second` is already live.
|
||||
history.undo()
|
||||
history.register(coarseStep(holding: CommentIdent.two, purging: store, inCard: cardID))
|
||||
|
||||
#expect(!first.retirement!.isOwed, "the superseded step retired, and its purge ran")
|
||||
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "its own backing went with it")
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"),
|
||||
"and the other session's backing is still there for its ⌘Z")
|
||||
}
|
||||
|
||||
@Test("An open card window owns its card's comment trash — the purge defers entirely")
|
||||
func anOpenWindowDefersThePurge() throws {
|
||||
// The carve-out (ruled 2026-08-06): "an open card window is itself an owner of its card's
|
||||
// comment trash ... because entries deleted in the live session are backed by the window's
|
||||
// fine steps, which the board-stack inventory cannot see". Total, not per entry — an unowned
|
||||
// leftover defers with the rest, because while a window is open the inventory is silent
|
||||
// rather than merely incomplete.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.cardWindowDidOpen(inCard: cardID)
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "deferred to the window's close")
|
||||
|
||||
store.cardWindowDidClose(inCard: cardID)
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty, "which settles it by the same gate")
|
||||
}
|
||||
|
||||
@Test("Only the open card's trash defers — another card's purge is untouched")
|
||||
func theDeferralIsPerCard() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
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: cardTitle))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Ship it"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.one)", commentText())
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.cardWindowDidOpen(inCard: cardID)
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card2))
|
||||
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.one)"))
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/comments/.trash/\(CommentIdent.two)"))
|
||||
}
|
||||
|
||||
/// One card window over the fixture's card, wired exactly as `CardWindowHost` wires one — the two
|
||||
/// `static` configure calls, plus the marking its `openCommentThread` does beside the sweep.
|
||||
private func openWindow(_ fixture: WriterFixture, on store: BoardStore, card: String) throws -> CardWindowSession {
|
||||
let session = CardWindowSession()
|
||||
CardWindowHost.configureUndo(session, store: store, cardID: cardID)
|
||||
CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
||||
session.comments.isEditable = true
|
||||
session.comments.cardFolder = fixture.url(card)
|
||||
session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished }
|
||||
session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(card)).body)
|
||||
store.cardWindowDidOpen(inCard: cardID)
|
||||
session.comments.open()
|
||||
return session
|
||||
}
|
||||
|
||||
@Test("The close gives up ownership before it purges — its own purge is never self-deferred")
|
||||
func theCloseUnmarksBeforeItPurges() async throws {
|
||||
// The ordering the carve-out lives or dies on (`CardWindowSession.endSession`): the no-step
|
||||
// close runs `purgeTrashNow()` itself, so a window that unmarked *after* that call would defer
|
||||
// its own purge into a no-op and hand the work to nobody.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let session = try openWindow(fixture, on: store, card: card)
|
||||
|
||||
// A crashed sibling's leftover, landing after this window's own open-time sweep had run: the
|
||||
// window owns it while it is open, and owes it at the close.
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "deferred while the window is open")
|
||||
|
||||
await session.endSession()
|
||||
|
||||
#expect(!history.canUndo, "nothing net happened — no coarse step took the purge on")
|
||||
#expect(store.openCardWindows.isEmpty, "the close gave the ownership back, first")
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty, "so the close's own purge ran")
|
||||
}
|
||||
|
||||
@Test("A live session's delete survives a purge the board stack cannot see it backing")
|
||||
func aLiveSessionsDeleteSurvivesAForeignPurge() async throws {
|
||||
// Why the carve-out has to be total: the delete's step is on the *window's* stack, so
|
||||
// `backedContent` — the board's — genuinely names nothing, and an ungated purge would remove
|
||||
// the folder the window's own ⌘Z restores from. The close then settles it by the same gate:
|
||||
// the coarse step becomes the owner, and the entry stays for as long as that step does.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let session = try openWindow(fixture, on: store, card: card)
|
||||
|
||||
session.comments.reload()
|
||||
session.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
||||
#expect(!history.canUndo, "the fine step is the window's, not the board's")
|
||||
|
||||
// Another card's retirement, firing while this window is open.
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
||||
"the open window owns it — the board's inventory could not have known")
|
||||
|
||||
await session.endSession()
|
||||
|
||||
#expect(history.canUndo, "the close folded the session into the coarse step")
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
||||
"which is now the owner, by the same gate")
|
||||
history.undo()
|
||||
#expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)), "so the coarse ⌘Z restores it")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Undo
|
||||
|
||||
@MainActor
|
||||
@@ -541,19 +738,47 @@ struct CommentUndoTests {
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
||||
}
|
||||
|
||||
@Test("A purge leaves the delete step stale — it skips with a banner, resurrecting nothing")
|
||||
func purgeMakesDeleteStepsStale() throws {
|
||||
@Test("A purge spares what a live step backs — under the gate it cannot stale one")
|
||||
func purgeSparesALiveStepsBacking() throws {
|
||||
// The flip of what this test used to pin (13-native-undo.md ▸ Interaction with the trash,
|
||||
// ruled 2026-08-06): "**Every purge of `comments/.trash/` is per-entry behind the ownership
|
||||
// gate** ... Under the gate a purge cannot stale a live step by construction." The delete's
|
||||
// own step is the live one — its undo is the move back out of `comments/.trash/`, which is
|
||||
// exactly the backing claim `backedContent` inventories.
|
||||
let (fixture, card) = try board()
|
||||
defer { fixture.tearDown() }
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText())
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
|
||||
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
|
||||
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
||||
"the purge asked the stack first — this is a step's backing, not residue")
|
||||
#expect(history.canUndo)
|
||||
|
||||
history.undo()
|
||||
#expect(fixture.exists(path), "so ⌘Z still restores the comment")
|
||||
#expect(store.banners.signposts.isEmpty, "and nothing was stale, so nothing was said")
|
||||
}
|
||||
|
||||
@Test("A hand-removed trash entry does stale the delete step — lazily, with the banner")
|
||||
func aForeignRemovalStalesTheDeleteStep() throws {
|
||||
// Lazy invalidation is unchanged (13 ▸ Rules); what changed on 2026-08-06 is that the app's
|
||||
// own purge is no longer a source of it. So the staleness has to come from somewhere genuinely
|
||||
// foreign — a hand-editor emptying `comments/.trash/` in Finder, which is what this simulates.
|
||||
let (fixture, card) = try board()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
|
||||
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
|
||||
try FileManager.default.removeItem(at: fixture.url("\(card)/comments/.trash/\(CommentIdent.one)"))
|
||||
#expect(history.canUndo, "invalidation is lazy — the stack still looks full")
|
||||
|
||||
history.undo()
|
||||
#expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)))
|
||||
#expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)), "resurrecting nothing")
|
||||
#expect(!history.canUndo)
|
||||
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
|
||||
}
|
||||
@@ -572,6 +797,9 @@ struct CommentUndoTests {
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
|
||||
#expect(!history.canUndo, "no byte capture in any tier — 13's rule")
|
||||
// The gate did not make the purge inert: nothing on the stack names this entry, so it goes —
|
||||
// per entry, and still without a step to show for it.
|
||||
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"))
|
||||
}
|
||||
|
||||
@Test("Comment expectations validate disk, which is why comments need no snapshot")
|
||||
|
||||
Reference in New Issue
Block a user