Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the no-undo case is gone, makeHistoryProvider answers git or native, and the native path provably never touches the enclosing repository's .git. Session undo steps anchor by card identity, never by path (9119aa1): HistoryAnchor carries the card UUID (plus comment/draft vocabulary) and apply-time validation resolves the current folder via the same both-container walk writeCardBody uses — a board-side lane or trash move no longer stales the coarse close step, while a genuine field collision still skips it whole. 2448 tests in 423 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -320,12 +320,13 @@ struct CardSessionCloseTests {
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A window whose card left the board registers nothing")
|
||||
func aVanishedCardRegistersNoSessionStep() async throws {
|
||||
@Test("A window whose card was trashed still registers — the trash is a relocation, not a vanishing")
|
||||
func aTrashedCardStillRegistersItsSession() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
let original = try body(fixture, cardPath)
|
||||
editBody(window, to: "Edited.\n")
|
||||
|
||||
// The card window's own Actions ▸ Delete — a board gesture, on the board's stack, which
|
||||
@@ -336,7 +337,34 @@ struct CardSessionCloseTests {
|
||||
#expect(window.board.undoActionName == "Delete Card")
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(window.board.undoActionName == "Delete Card", "no session step for a card that has gone")
|
||||
// "A tracked relocation — a lane move mid-session or after close, **a trash move** — never
|
||||
// stales the step" (13 ▸ Rules, ruled 2026-07-31): the session resolves through the walk that
|
||||
// spans both containers, so the step registers over the delete rather than being dropped.
|
||||
#expect(window.board.undoActionName == "Edit Card")
|
||||
|
||||
window.board.undo()
|
||||
#expect(window.store.banners.signposts.isEmpty, "nothing about the card's content changed")
|
||||
#expect(try body(fixture, ".trash/\(Ident.card1)") == original,
|
||||
"walked back where the card is now, its subtree intact")
|
||||
#expect(window.board.undoActionName == "Delete Card", "and the delete is the next step down")
|
||||
}
|
||||
|
||||
@Test("A card that resolves nowhere registers nothing")
|
||||
func aCardThatResolvesNowhereRegistersNoSessionStep() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
editBody(window, to: "Edited.\n")
|
||||
|
||||
// Purged, or moved out of the board — neither container holds the card, so there is no folder
|
||||
// for the step's components to be about and "the honest skip" is not to register one at all.
|
||||
try FileManager.default.removeItem(at: fixture.url(cardPath))
|
||||
window.store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await window.store.awaitQuiescence()
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(!window.board.canUndo, "no session step for a card that is nowhere")
|
||||
}
|
||||
|
||||
@Test("A style change made in the window's sidebar joins the session, not the board")
|
||||
@@ -421,6 +449,127 @@ struct CardSessionStalenessTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity anchoring
|
||||
|
||||
/// **Session steps anchor by card identity, never by path** (13-native-undo.md ▸ Rules, ruled
|
||||
/// 2026-07-31) — the defect these tests exist for, stated as the gesture that produced it: a card
|
||||
/// window's steps carried lane-bearing folder paths, so one board-side lane move staled *every*
|
||||
/// component of the session step at once and the whole session skipped, though nothing about the
|
||||
/// card's content had changed.
|
||||
///
|
||||
/// The claim is not "moves are ignored". It is that a **relocation** and a **content change** are
|
||||
/// different questions and only the second is what validation exists to catch — so the collision half
|
||||
/// is pinned here beside the round trip, at the card's *new* home.
|
||||
@MainActor
|
||||
@Suite("Card session undo ▸ identity anchoring")
|
||||
struct CardSessionAnchorTests {
|
||||
|
||||
/// The one-lane comment board with somewhere to move the card to.
|
||||
@MainActor
|
||||
private func twoLaneBoard(_ fixture: WriterFixture) throws -> String {
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
return card
|
||||
}
|
||||
|
||||
/// The card's folder after a move into the second lane.
|
||||
private let movedPath = "\(Ident.lane2)/\(Ident.card1)"
|
||||
|
||||
@Test("A board-side lane move never stales the session — all three gestures still apply")
|
||||
func aLaneMoveDoesNotStaleTheSessionStep() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try twoLaneBoard(fixture)
|
||||
let deleted = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(deleted, commentText(body: "kept somewhere\n"))
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
let original = try body(fixture, cardPath)
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
let posted = try #require(postComment(window, body: "A remark.\n"))
|
||||
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
|
||||
// The board moves the card to another lane while its window is open — a drag on the board,
|
||||
// which is a board gesture on the board's own stack. Every folder the session wrote to has
|
||||
// just changed path.
|
||||
window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
|
||||
window.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await window.store.awaitQuiescence()
|
||||
#expect(fixture.exists(movedPath))
|
||||
#expect(window.board.undoActionName == "Move Card")
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(window.board.undoActionName == "Edit Card", "the session registered over the move")
|
||||
|
||||
window.board.undo()
|
||||
#expect(window.store.banners.signposts.isEmpty, "a relocation is not a collision")
|
||||
#expect(try body(fixture, movedPath) == original, "the body is back — at the card's new home")
|
||||
#expect(!fixture.exists("\(movedPath)/comments/\(posted.rawValue)"), "the post is unposted")
|
||||
#expect(fixture.exists("\(movedPath)/comments/.draft"))
|
||||
#expect(fixture.exists("\(movedPath)/comments/\(CommentIdent.one)"),
|
||||
"and the deleted comment is back, out of the trash that travelled with the card")
|
||||
#expect(window.board.undoActionName == "Move Card", "the move is the next step down")
|
||||
}
|
||||
|
||||
@Test("A genuine field collision at the card's new home still skips the whole step")
|
||||
func aFieldCollisionAfterAMoveStillSkipsWhole() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try twoLaneBoard(fixture)
|
||||
let deleted = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(deleted, commentText(body: "kept somewhere\n"))
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
|
||||
window.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await window.store.awaitQuiescence()
|
||||
await window.session.endSession()
|
||||
|
||||
// Somebody else rewrites the body — at the folder the card now sits in, which is exactly where
|
||||
// the anchor resolves. One component, and the whole step goes (13: "any stale component skips
|
||||
// the whole step — never a partial session revert").
|
||||
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(movedPath), body: "Somebody else.\n")
|
||||
|
||||
window.board.undo()
|
||||
#expect(window.store.banners.signposts.map(\.message)
|
||||
== ["Undo skipped — 'Fix login' changed outside Lanework"])
|
||||
// The skip pops the step and **⌘Z falls through to the next one down** (13 ▸ Rules), which
|
||||
// here is the board's own move — so the card, the foreign body and the still-deleted comment
|
||||
// all travel back to the lane the move took them from.
|
||||
#expect(fixture.exists(cardPath))
|
||||
#expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write")
|
||||
#expect(!fixture.exists("\(cardPath)/comments/\(CommentIdent.one)"),
|
||||
"and the comment half did not half-happen either")
|
||||
#expect(try fixture.entryNames("\(cardPath)/comments/.trash").isEmpty,
|
||||
"the skipped step retired, so the backing it was holding was purged with it")
|
||||
#expect(!window.board.canUndo, "both steps are gone — one skipped, one applied")
|
||||
}
|
||||
|
||||
@Test("The window's own ⌘Z survives a lane move too — the fine steps carry the same anchors")
|
||||
func theWindowStackSurvivesALaneMove() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try twoLaneBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
let original = try body(fixture, cardPath)
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
|
||||
window.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await window.store.awaitQuiescence()
|
||||
|
||||
// "The coarse step — **and the window's fine steps it folds**" (13 ▸ Rules): the fold is only
|
||||
// as sound as its components, so the window's own stack is anchored the same way.
|
||||
window.window.stack.undo()
|
||||
#expect(try body(fixture, movedPath) == original)
|
||||
#expect(window.store.banners.signposts.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The deferred purge
|
||||
|
||||
@MainActor
|
||||
@@ -543,8 +692,10 @@ struct CardSessionPurgeTests {
|
||||
|
||||
@Test("A board with no substrate at all owes the purge immediately")
|
||||
func noProviderRunsTheRetirementAtOnce() throws {
|
||||
// Repo-nested boards bind no provider (06-history-undo.md ▸ Rules), so nothing could ever
|
||||
// report the step's death — the work is owed at registration or never.
|
||||
// A store with no session behind it — no board the app composes binds no provider any more
|
||||
// (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: repo-nested boards bind the native
|
||||
// stack too). Nothing could ever report the step's death, so the work is owed at registration
|
||||
// or never.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
@@ -554,10 +554,12 @@ struct CommentUndoTests {
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText())
|
||||
|
||||
#expect(HistoryStaleness.isCurrent([.present(fixture.url(path))]))
|
||||
#expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))]))
|
||||
// Path anchors resolve to themselves — the resolver a caller with no board hands in
|
||||
// (`HistoryAnchor.literalPath`); the card-anchored halves are `CardSessionUndoTests`'.
|
||||
#expect(HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
|
||||
#expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))], resolvedBy: \.literalPath))
|
||||
try FileManager.default.removeItem(at: fixture.url(path))
|
||||
#expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))]))
|
||||
#expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -898,17 +898,21 @@ struct GitUndoSessionTests {
|
||||
|
||||
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12-editions.md
|
||||
/// ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md ▸ Rules), stated as the
|
||||
/// matrix it is: gitless boards bind the native stack in *every* tier ("an upgrade never removes
|
||||
/// undo"), a Pro git board binds the git provider, and a repo-nested board binds nothing.
|
||||
/// matrix it is: **boards without app-managed git — repo-nested included — bind the native stack in
|
||||
/// *every* tier** ("an upgrade never removes undo"), and a Pro git board binds the git provider.
|
||||
/// There is no third answer any more.
|
||||
///
|
||||
/// ### The free tier's row is one cell wide, structurally
|
||||
/// ### The repo-nested row stopped being an exception
|
||||
///
|
||||
/// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all
|
||||
/// and cannot tell a repo-nested board from a plain one — which is not an omission but 12 ▸ The free
|
||||
/// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a
|
||||
/// 1.x board, **a repo-nested board**) works normally — files read and write as on any board,
|
||||
/// **native undo runs**". 06's no-undo rule for repo-nested boards is a rule of a doc whose own first
|
||||
/// line reads "Tier scope: Lanework Pro", and the tests below pin both halves.
|
||||
/// **native undo runs**". Pro used to answer differently on the same board, which made subscribing
|
||||
/// *remove* ⌘Z from it; the re-ruling of 2026-07-31 retired that case outright — "leave strictly
|
||||
/// alone concerns *git*, and this stack never touches git — memory-only, journal-free,
|
||||
/// session-scoped" (13's header) — so the two tiers now agree on every board there is, and the tests
|
||||
/// below pin both halves of that agreement.
|
||||
@MainActor
|
||||
@Suite("Git undo ▸ which board gets a provider")
|
||||
struct GitUndoBindingTests {
|
||||
@@ -977,8 +981,8 @@ struct GitUndoBindingTests {
|
||||
#expect(log.crossings == ["undo Move Card"])
|
||||
}
|
||||
|
||||
@Test("Pro on a repo-nested board binds no provider — the one no-undo case")
|
||||
func proOnARepoNestedBoardBindsNothing() throws {
|
||||
@Test("Pro on a repo-nested board binds the native stack — the no-undo case is retired")
|
||||
func proOnARepoNestedBoardBindsTheNativeStack() throws {
|
||||
let outer = try WriterFixture()
|
||||
defer { outer.tearDown() }
|
||||
// A repository at the *parent*, with the board inside it — the nested posture.
|
||||
@@ -995,16 +999,26 @@ struct GitUndoBindingTests {
|
||||
let ref = try openBoard(model, at: boardRoot)
|
||||
let session = try #require(model.session(for: ref))
|
||||
|
||||
// 06 ▸ Rules: a board inside somebody else's repository is "left strictly alone … so they get
|
||||
// **no undo**" — no app-managed undo journal, which an in-memory stack here would be.
|
||||
// The mode is detected — Pro walks the ancestors — and it no longer decides ⌘Z: "Pro binds it
|
||||
// on mode-none **and repo-nested** boards alike … what repo-nested denies is app-managed
|
||||
// history, never ⌘Z" (13's header, re-ruled 2026-07-31).
|
||||
#expect(session.gitMode == .repoNested)
|
||||
#expect(session.history == nil, "the app leaves that repository strictly alone")
|
||||
#expect(session.undoManager.canUndo == false)
|
||||
#expect(session.undoManager.canRedo == false)
|
||||
#expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name")
|
||||
// And a crossing that somehow started still writes nothing.
|
||||
#expect(session.history is NativeHistoryProvider)
|
||||
#expect(session.undoManager.canUndo == false, "empty, not absent")
|
||||
|
||||
// A working stack, exactly as the free tier's on this same board — which is the whole point
|
||||
// of the re-ruling: the two tiers answer alike here now.
|
||||
let log = StepLog()
|
||||
session.history?.register(log.step("Move Card"))
|
||||
#expect(session.undoManager.canUndo)
|
||||
#expect(session.undoManager.undoMenuItemTitle == "Undo Move Card")
|
||||
session.undoManager.undo()
|
||||
session.undoManager.redo()
|
||||
#expect(log.crossings == ["undo Move Card"])
|
||||
|
||||
// And the enclosing repository is still left strictly alone: no repository was created at the
|
||||
// board, and nothing here reaches for the ancestor's `.git` (13: memory-only, journal-free).
|
||||
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
|
||||
#expect(session.git?.committer == nil, "no committer, and so nothing that could write there")
|
||||
}
|
||||
|
||||
@Test("The free tier's repo-nested board still binds the native stack — it never detects one")
|
||||
@@ -1031,6 +1045,8 @@ struct GitUndoBindingTests {
|
||||
#expect(session.git == nil)
|
||||
#expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode")
|
||||
#expect(session.history is NativeHistoryProvider)
|
||||
// Pro on this same board now answers identically (above) — the two tiers agree, which is what
|
||||
// the re-ruling of 2026-07-31 bought: an upgrade never removes undo from *any* board.
|
||||
}
|
||||
|
||||
@Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded")
|
||||
|
||||
Reference in New Issue
Block a user