Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.
Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.
2444 tests in 422 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
617 lines
27 KiB
Swift
617 lines
27 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 left the board registers nothing")
|
|
func aVanishedCardRegistersNoSessionStep() async throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
_ = try makeCommentBoard(fixture)
|
|
let window = try makeWindow(fixture)
|
|
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()
|
|
#expect(window.board.undoActionName == "Delete Card", "no session step for a card that has gone")
|
|
}
|
|
|
|
@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: - 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 {
|
|
// 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.
|
|
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"))])
|
|
}
|
|
}
|