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
768 lines
35 KiB
Swift
768 lines
35 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// **The two-level undo model** — a card window's own stack, and the one coarse step its close
|
|
/// registers on the board's (13-native-undo.md ▸ Rules, re-ruled 2026-07-31; 05-card-window.md ▸ The
|
|
/// comments column; 01-storage-format.md § Enhanced schema ▸ the deferred purge).
|
|
///
|
|
/// The claims here are all about *which stack* and *when*, which is exactly the class of thing that
|
|
/// looks right in a running window and is wrong: a step on the board's stack while a window is still
|
|
/// open, a purge that ran while an undo still needed the folder it removed, a session that registered
|
|
/// a step for a card that had left the board. So every test drives the production wiring
|
|
/// (`CardWindowHost.configureUndo` / `.configureComments`, both `static` for this reason) over a real
|
|
/// store and asserts against the two stacks and the bytes on disk.
|
|
///
|
|
/// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider
|
|
/// grammar is `HistoryProviderTests`'.
|
|
|
|
// MARK: - The window under test
|
|
|
|
@MainActor
|
|
private struct Window {
|
|
let store: BoardStore
|
|
/// The **board's** stack — what must stay empty while the window is open.
|
|
let board: NativeHistoryProvider
|
|
let session: CardWindowSession
|
|
|
|
var window: CardWindowUndo { session.undo }
|
|
var comments: CardComments { session.comments }
|
|
var body: CardBodyEditSession { session.body }
|
|
}
|
|
|
|
private let cardID = ItemID(rawValue: Ident.card1)
|
|
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
|
|
|
/// A card window over a board with one card, wired exactly as `CardWindowHost` wires one.
|
|
@MainActor
|
|
private func makeWindow(_ fixture: WriterFixture) throws -> Window {
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let board = NativeHistoryProvider()
|
|
store.history = board
|
|
|
|
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(cardPath)
|
|
// The one seam the host wires beside the undo ones and this suite is not testing — the buffer's
|
|
// write target (`configureSession`), spelled the same way.
|
|
session.body.save = { [weak store] text in
|
|
store?.writeCardBody(inCard: cardID, body: text) ?? .vanished
|
|
}
|
|
session.comments.open()
|
|
session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
|
|
return Window(store: store, board: board, session: session)
|
|
}
|
|
|
|
@MainActor
|
|
private func editBody(_ window: Window, to text: String) {
|
|
window.body.edited(text)
|
|
window.body.endEditSession()
|
|
}
|
|
|
|
@MainActor
|
|
private func postComment(_ window: Window, body: String) -> ItemID? {
|
|
window.comments.composer.edited(body)
|
|
_ = window.comments.composer.flush()
|
|
return window.comments.composer.postNow()
|
|
}
|
|
|
|
private func body(_ fixture: WriterFixture, _ path: String) throws -> String {
|
|
try FrontmatterDocument.parse(fixture.indexText(path)).body
|
|
}
|
|
|
|
// MARK: - Routing
|
|
|
|
@MainActor
|
|
@Suite("Card session undo ▸ routing")
|
|
struct CardSessionRoutingTests {
|
|
|
|
@Test("A body edit and a comment post register on the window's stack, never on the board's")
|
|
func windowGesturesStayOffTheBoardStack() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
_ = try makeCommentBoard(fixture)
|
|
let window = try makeWindow(fixture)
|
|
|
|
editBody(window, to: "Edited in the window.\n")
|
|
#expect(postComment(window, body: "A remark.\n") != nil)
|
|
|
|
#expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps")
|
|
#expect(window.window.stack.canUndo)
|
|
#expect(window.window.stack.undoActionName == "Comment")
|
|
}
|
|
|
|
@Test("⌘Z in the window walks its own gestures, newest first")
|
|
func theWindowStackWalksItsOwnGestures() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let window = try makeWindow(fixture)
|
|
let original = try body(fixture, cardPath)
|
|
|
|
editBody(window, to: "Edited in the window.\n")
|
|
let posted = try #require(postComment(window, body: "A remark.\n"))
|
|
|
|
// Newest first: the post, then the body edit.
|
|
window.window.stack.undo()
|
|
#expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"))
|
|
#expect(fixture.exists("\(card)/comments/.draft"))
|
|
#expect(try body(fixture, cardPath) == "Edited in the window.\n")
|
|
|
|
window.window.stack.undo()
|
|
#expect(try body(fixture, cardPath) == original)
|
|
#expect(!window.window.stack.canUndo)
|
|
#expect(!window.board.canUndo, "and the board's stack was never involved")
|
|
}
|
|
|
|
@Test("A comment's inline edit session is one window step, at its commit point")
|
|
func inlineEditRegistersOneWindowStep() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let path = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(path, commentText(body: "original\n"))
|
|
let window = try makeWindow(fixture)
|
|
window.comments.reload()
|
|
|
|
window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
window.comments.editing?.edited("first pass\n")
|
|
window.comments.editing?.flush()
|
|
window.comments.editing?.edited("second pass\n")
|
|
window.comments.editing?.flush()
|
|
#expect(!window.window.stack.canUndo, "a save tick is not a step")
|
|
|
|
window.comments.commitEdit()
|
|
#expect(window.window.stack.undoActionName == "Edit Comment")
|
|
#expect(!window.board.canUndo)
|
|
|
|
window.window.stack.undo()
|
|
#expect(try body(fixture, path) == "original\n", "back to the bytes the session opened on")
|
|
}
|
|
|
|
@Test("An inline session's Cancel registers nothing — its net effect is nothing")
|
|
func cancelRegistersNothing() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(body: "original\n"))
|
|
let window = try makeWindow(fixture)
|
|
window.comments.reload()
|
|
|
|
window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
window.comments.editing?.edited("rewritten\n")
|
|
window.comments.editing?.flush()
|
|
window.comments.cancelEdit()
|
|
|
|
#expect(!window.window.stack.canUndo)
|
|
#expect(!window.board.canUndo)
|
|
}
|
|
|
|
@Test("The window's manager answers for the window's stack, and never falls through")
|
|
func theWindowManagerNeverFallsThrough() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
_ = try makeCommentBoard(fixture)
|
|
let window = try makeWindow(fixture)
|
|
|
|
// A board step exists — a rename made from the board window, say.
|
|
window.store.renameBoard("Renamed")
|
|
#expect(window.board.canUndo)
|
|
|
|
// The window's own stack is empty, so its manager says no: "exhausting the window stack
|
|
// beeps; it never reaches board history" (06-history-undo.md ▸ Undo routing).
|
|
#expect(!window.window.manager.canUndo)
|
|
#expect(!window.window.manager.canRedo)
|
|
#expect(window.window.manager.undoMenuItemTitle == "Undo")
|
|
|
|
editBody(window, to: "Edited.\n")
|
|
#expect(window.window.manager.canUndo)
|
|
#expect(window.window.manager.undoMenuItemTitle == "Undo Edit Card")
|
|
}
|
|
|
|
@Test("The read-only lock disables the window's pair, and the stack survives it")
|
|
func theLockDisablesTheWindowsPair() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
_ = try makeCommentBoard(fixture)
|
|
let window = try makeWindow(fixture)
|
|
editBody(window, to: "Edited.\n")
|
|
#expect(window.window.manager.canUndo)
|
|
|
|
window.store.enterVanishedRootLock()
|
|
#expect(!window.window.manager.canUndo, "13 ▸ Rules ▸ locks, one level down")
|
|
#expect(window.window.stack.canUndo, "the stack itself is untouched")
|
|
}
|
|
}
|
|
|
|
// MARK: - The coarse close step
|
|
|
|
@MainActor
|
|
@Suite("Card session undo ▸ the coarse close step")
|
|
struct CardSessionCloseTests {
|
|
|
|
@Test("A session of three gestures closes as exactly one board step, and undo restores all three")
|
|
func oneStepForTheWholeSession() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(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))
|
|
|
|
await window.session.endSession()
|
|
|
|
// One step, named for the session rather than for any gesture inside it.
|
|
#expect(window.board.canUndo)
|
|
#expect(window.board.undoActionName == "Edit Card")
|
|
|
|
window.board.undo()
|
|
#expect(!window.board.canUndo, "exactly one")
|
|
#expect(try body(fixture, cardPath) == original)
|
|
#expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"), "the post is unposted")
|
|
#expect(fixture.exists("\(card)/comments/.draft"))
|
|
#expect(fixture.exists(deleted), "the deleted comment is back — restored from comments/.trash/")
|
|
#expect(try body(fixture, deleted) == "kept somewhere\n")
|
|
|
|
window.board.redo()
|
|
#expect(try body(fixture, cardPath) == "Edited in the window.\n")
|
|
#expect(fixture.exists("\(card)/comments/\(posted.rawValue)"))
|
|
#expect(!fixture.exists(deleted))
|
|
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
|
}
|
|
|
|
@Test("A session with no net change registers nothing")
|
|
func noNetChangeRegistersNothing() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
_ = try makeCommentBoard(fixture)
|
|
let window = try makeWindow(fixture)
|
|
let original = try body(fixture, cardPath)
|
|
|
|
// Two Edit sessions whose net effect on the file is nothing at all.
|
|
editBody(window, to: "A detour.\n")
|
|
editBody(window, to: original)
|
|
#expect(window.window.stack.canUndo, "two real gestures, on the window's stack")
|
|
|
|
await window.session.endSession()
|
|
#expect(!window.board.canUndo, "a session with no net change registers nothing")
|
|
#expect(try body(fixture, cardPath) == original)
|
|
}
|
|
|
|
@Test("A gesture undone inside the window is not part of the session's net effect")
|
|
func anUndoneGestureLeavesTheFold() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let path = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(path, commentText())
|
|
let window = try makeWindow(fixture)
|
|
window.comments.reload()
|
|
|
|
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
window.window.stack.undo()
|
|
#expect(fixture.exists(path), "the window's own ⌘Z put it back")
|
|
|
|
await window.session.endSession()
|
|
#expect(!window.board.canUndo, "nothing is left of the session to coarsen")
|
|
}
|
|
|
|
@Test("A comment edited and then deleted in one session folds to where it actually is")
|
|
func editThenDeleteFoldsToTheTrash() async throws {
|
|
// The fold's move case: the edit's expectations name the comment's live path, and the delete
|
|
// then empties that path. A fold that kept both would be stale at the moment it registered.
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let path = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(path, commentText(body: "original\n"))
|
|
let window = try makeWindow(fixture)
|
|
window.comments.reload()
|
|
|
|
window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
window.comments.editing?.edited("rewritten\n")
|
|
window.comments.commitEdit()
|
|
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
await window.session.endSession()
|
|
#expect(window.board.undoActionName == "Edit Card")
|
|
|
|
window.board.undo()
|
|
#expect(window.store.banners.signposts.isEmpty, "the session's own step is never stale on arrival")
|
|
#expect(fixture.exists(path))
|
|
#expect(try body(fixture, path) == "original\n", "restored to the bytes the session opened on")
|
|
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
|
}
|
|
|
|
@Test("A comment posted and then deleted in one session unwinds to the draft it came from")
|
|
func postThenDeleteUnwindsToTheDraft() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let window = try makeWindow(fixture)
|
|
|
|
let posted = try #require(postComment(window, body: "A remark.\n"))
|
|
window.comments.reload()
|
|
window.comments.delete(posted)
|
|
await window.session.endSession()
|
|
|
|
window.board.undo()
|
|
#expect(window.store.banners.signposts.isEmpty)
|
|
#expect(fixture.exists("\(card)/comments/.draft"))
|
|
#expect(try body(fixture, "\(card)/comments/.draft") == "A remark.\n")
|
|
#expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"))
|
|
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
|
}
|
|
|
|
@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
|
|
// dismisses this window (05-card-window.md ▸ Deletion & lifecycle).
|
|
window.store.deleteCard(cardID)
|
|
window.store.handleWatcherEvent(.treeChanged(.appMediated))
|
|
await window.store.awaitQuiescence()
|
|
#expect(window.board.undoActionName == "Delete Card")
|
|
|
|
await window.session.endSession()
|
|
// "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")
|
|
func sidebarStylingIsASessionGesture() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
_ = try makeCommentBoard(fixture)
|
|
let window = try makeWindow(fixture)
|
|
|
|
// The sidebar anchor's write, exactly as `CardStyleSection` makes it.
|
|
window.store.applyStyle(
|
|
to: CardStyleSection.target(forCard: cardID),
|
|
background: .set("blue"),
|
|
on: window.window
|
|
)
|
|
#expect(window.window.stack.undoActionName == "Restyle Card")
|
|
#expect(!window.board.canUndo)
|
|
|
|
await window.session.endSession()
|
|
#expect(window.board.undoActionName == "Edit Card")
|
|
|
|
window.board.undo()
|
|
let document = try FrontmatterDocument.parse(fixture.indexText(cardPath))
|
|
#expect(document.background.isMissing, "the session's net effect, walked back")
|
|
}
|
|
}
|
|
|
|
// MARK: - Transactional staleness
|
|
|
|
@MainActor
|
|
@Suite("Card session undo ▸ transactional staleness")
|
|
struct CardSessionStalenessTests {
|
|
|
|
@Test("One stale component skips the whole step — nothing is partially reverted")
|
|
func anyStaleComponentSkipsTheWholeStep() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let deleted = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(deleted, commentText())
|
|
let window = try makeWindow(fixture)
|
|
window.comments.reload()
|
|
|
|
editBody(window, to: "Edited in the window.\n")
|
|
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
await window.session.endSession()
|
|
#expect(window.board.canUndo)
|
|
|
|
// Somebody else rewrites the card's body after the window closed — one component of the
|
|
// session's step, and only one.
|
|
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n")
|
|
|
|
window.board.undo()
|
|
|
|
#expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write")
|
|
#expect(!fixture.exists(deleted), "and the comment half did not half-happen either")
|
|
#expect(!window.board.canUndo, "the stale step was popped")
|
|
#expect(window.store.banners.signposts.map(\.message)
|
|
== ["Undo skipped — 'Fix login' changed outside Lanework"])
|
|
}
|
|
|
|
@Test("A foreign change to a field the session never wrote leaves the step alone")
|
|
func anUnrelatedForeignChangeDoesNotSkip() 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 in the window.\n")
|
|
await window.session.endSession()
|
|
|
|
// A foreign styling of the same card: the session wrote the body and nothing else, so the
|
|
// step names no style field to be stale against (13 ▸ Rules, the field-level predicate).
|
|
try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) {
|
|
$0.set(FrontmatterKeys.background, to: .string("blue"))
|
|
}
|
|
|
|
window.board.undo()
|
|
#expect(try body(fixture, cardPath) == original)
|
|
#expect(window.store.banners.signposts.isEmpty)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
@Suite("Card session undo ▸ the deferred purge")
|
|
struct CardSessionPurgeTests {
|
|
|
|
/// A window whose session deleted one comment and closed — the state every test below starts in.
|
|
@MainActor
|
|
private func closedWithADeletedComment(_ fixture: WriterFixture) async throws -> (Window, String) {
|
|
let card = try makeCommentBoard(fixture)
|
|
let path = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(path, commentText())
|
|
let window = try makeWindow(fixture)
|
|
window.comments.reload()
|
|
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
await window.session.endSession()
|
|
return (window, card)
|
|
}
|
|
|
|
@Test("comments/.trash survives the close while the coarse step lives")
|
|
func theTrashOutlivesTheClose() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let (window, card) = try await closedWithADeletedComment(fixture)
|
|
|
|
#expect(window.board.canUndo)
|
|
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
|
"the step's undo restores from here — the purge waits for it")
|
|
}
|
|
|
|
@Test("The board session's end purges what the step was holding")
|
|
func theBoardSessionsEndPurges() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let (window, card) = try await closedWithADeletedComment(fixture)
|
|
|
|
// `AppModel`'s teardown, and the add-git swap, both do exactly this.
|
|
window.board.clear()
|
|
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
|
}
|
|
|
|
@Test("A stale step's skip purges too — the step is gone, so its backing is not needed")
|
|
func aSkippedStepPurges() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let (window, card) = try await closedWithADeletedComment(fixture)
|
|
|
|
// A foreign delete of the trashed folder is not the interesting collision; a foreign body
|
|
// rewrite is — it makes the step stale without touching what the purge would remove.
|
|
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n")
|
|
// The session wrote the body too, so the step names it.
|
|
window.board.undo()
|
|
|
|
#expect(!window.board.canUndo)
|
|
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
|
}
|
|
|
|
@Test("A session that registers no step purges at the close, as it always did")
|
|
func aSessionWithNoStepPurgesAtOnce() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
// Residue with no session behind it: nothing this close does could need it back.
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
|
let window = try makeWindow(fixture)
|
|
|
|
await window.session.endSession()
|
|
#expect(!window.board.canUndo)
|
|
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
|
}
|
|
|
|
@Test("A substrate that keeps no steps purges at the close — Pro's rule, structurally")
|
|
func aSubstrateThatKeepsNoStepsPurgesAtOnce() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let path = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(path, commentText())
|
|
let window = try makeWindow(fixture)
|
|
window.comments.reload()
|
|
// The git provider drops every registration (its substrate is the commit trail) and retires
|
|
// it on the way past — which is what makes "purge rides the close flush" true on Pro with no
|
|
// tier check at any call site. Bound directly here: `register` reads no repository.
|
|
window.store.history = GitHistoryProvider(boardRoot: fixture.root)
|
|
|
|
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
await window.session.endSession()
|
|
|
|
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
|
}
|
|
|
|
@Test("Undone-and-superseded releases the work the step was holding")
|
|
func supersedingAnUndoneStepRetiresIt() {
|
|
// The one release condition disk cannot show, because by the time it fires the coarse undo
|
|
// has already emptied the trash it was holding: a step undone and then superseded by a new
|
|
// gesture leaves history for good, and its retirement runs (13 ▸ Interaction with the trash).
|
|
let provider = NativeHistoryProvider()
|
|
let retirement = HistoryStep.Retirement {}
|
|
provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
|
|
#expect(retirement.isOwed)
|
|
|
|
provider.undo()
|
|
#expect(retirement.isOwed, "an undone step is still crossable — redo would need its backing")
|
|
|
|
provider.register(HistoryStep(name: "Move Card", undo: { _ in .applied }, redo: { _ in .applied }))
|
|
#expect(!retirement.isOwed, "the redo stack cleared, so the step is gone for good")
|
|
}
|
|
|
|
@Test("A retirement runs once, whatever a provider does to the step")
|
|
func aRetirementRunsOnce() {
|
|
var runs = 0
|
|
let retirement = HistoryStep.Retirement { runs += 1 }
|
|
let provider = NativeHistoryProvider()
|
|
provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
|
|
|
|
provider.clear()
|
|
provider.clear()
|
|
#expect(runs == 1)
|
|
}
|
|
|
|
@Test("A board with no substrate at all owes the purge immediately")
|
|
func noProviderRunsTheRetirementAtOnce() throws {
|
|
// 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)
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
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(cardPath)
|
|
|
|
var purged = false
|
|
_ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true })
|
|
#expect(purged == false, "no net effect — nothing was registered, and the caller still owes it")
|
|
|
|
session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished }
|
|
session.body.adopt(diskBody: try body(fixture, cardPath))
|
|
session.body.edited("Edited.\n")
|
|
session.body.endEditSession()
|
|
_ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true })
|
|
#expect(purged, "a step nothing keeps is a step nothing can retire later")
|
|
}
|
|
}
|
|
|
|
// MARK: - The fold
|
|
|
|
@MainActor
|
|
@Suite("Card session undo ▸ the fold")
|
|
struct CardSessionFoldTests {
|
|
|
|
private let folder = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
|
|
|
|
@Test("Merging is per target and per field, later entries winning")
|
|
func laterEntriesWin() {
|
|
let other = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true)
|
|
let fold = CardWindowUndo.fold([
|
|
[.present(folder, .background("blue"), .body("first\n"))],
|
|
[.present(folder, .background("green")), .present(other)],
|
|
])
|
|
|
|
#expect(fold.expectations == [
|
|
.present(folder, .background("green"), .body("first\n")),
|
|
.present(other),
|
|
])
|
|
}
|
|
|
|
@Test("Two folds of the same writes, read from both ends, are equal only when nothing changed")
|
|
func equalityIsTheNoNetChangeTest() {
|
|
let there = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("b\n"))]])
|
|
let back = CardWindowUndo.fold([[.present(folder, .body("b\n"))], [.present(folder, .body("a\n"))]])
|
|
#expect(there != back)
|
|
|
|
let round = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("a\n"))]])
|
|
#expect(round == CardWindowUndo.fold([[.present(folder, .body("a\n"))]]))
|
|
}
|
|
|
|
@Test("Presence takes the later answer, and an absence clears what was said about the path")
|
|
func presenceTakesTheLaterAnswer() {
|
|
let live = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true)
|
|
// An edit, then the delete that emptied the path: a fold still expecting the edited bytes
|
|
// there would be stale on arrival.
|
|
let fold = CardWindowUndo.fold([[.present(live, .body("rewritten\n"))], [.absent(live)]])
|
|
#expect(fold.expectations == [.absent(live)])
|
|
|
|
// And back again — a restore names the path present, which is the ordinary later-wins.
|
|
let restored = CardWindowUndo.fold([[.absent(live)], [.present(live, .body("original\n"))]])
|
|
#expect(restored.expectations == [.present(live, .body("original\n"))])
|
|
}
|
|
}
|