Implement staleness validation and skip-with-banner
Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -20,25 +20,35 @@ private final class StepLog {
|
||||
|
||||
private(set) var crossings: [String] = []
|
||||
|
||||
/// The direction each crossing was told it was — the argument the skip banner's verb comes from.
|
||||
private(set) var directions: [HistoryDirection] = []
|
||||
|
||||
/// A step that applies in both directions — the ordinary case.
|
||||
func step(_ name: String) -> HistoryStep {
|
||||
step(name, undo: .applied, redo: .applied)
|
||||
}
|
||||
|
||||
/// A step whose inverse declines — 13's staleness skip, without needing a foreign writer.
|
||||
/// A step whose inverse declines as stale — 13's skip, without needing a foreign writer.
|
||||
func staleStep(_ name: String) -> HistoryStep {
|
||||
step(name, undo: .skipped, redo: .applied)
|
||||
}
|
||||
|
||||
/// A step whose inverse could not be written — the disk-error fate, which is not staleness.
|
||||
func failingStep(_ name: String) -> HistoryStep {
|
||||
step(name, undo: .failed, redo: .applied)
|
||||
}
|
||||
|
||||
func step(_ name: String, undo: HistoryStepOutcome, redo: HistoryStepOutcome) -> HistoryStep {
|
||||
HistoryStep(
|
||||
name: name,
|
||||
undo: { [weak self] in
|
||||
undo: { [weak self] direction in
|
||||
self?.crossings.append("undo \(name)")
|
||||
self?.directions.append(direction)
|
||||
return undo
|
||||
},
|
||||
redo: { [weak self] in
|
||||
redo: { [weak self] direction in
|
||||
self?.crossings.append("redo \(name)")
|
||||
self?.directions.append(direction)
|
||||
return redo
|
||||
}
|
||||
)
|
||||
@@ -172,6 +182,55 @@ struct NativeHistoryProviderTests {
|
||||
#expect(provider.redoActionName == "Move Card")
|
||||
}
|
||||
|
||||
@Test("A step whose write failed stays on the stack, and the crossing stops there")
|
||||
func aFailedStepStaysAndStopsTheCrossing() {
|
||||
let log = StepLog()
|
||||
let provider = NativeHistoryProvider()
|
||||
provider.register(log.step("Move Card"))
|
||||
provider.register(log.failingStep("Rename Card"))
|
||||
|
||||
provider.undo()
|
||||
|
||||
// It was reached and it declined — and unlike a stale step it is still there to retry, with
|
||||
// the step below it untouched underneath.
|
||||
#expect(log.crossings == ["undo Rename Card"], "no fall-through: a refused disk is not a reason to try more")
|
||||
#expect(provider.canUndo)
|
||||
#expect(provider.undoActionName == "Rename Card")
|
||||
#expect(provider.canRedo == false, "nothing landed, so nothing is redoable")
|
||||
|
||||
provider.undo()
|
||||
#expect(log.crossings == ["undo Rename Card", "undo Rename Card"], "⌘Z can retry it")
|
||||
}
|
||||
|
||||
@Test("A failed redo leaves the redo stack alone too")
|
||||
func aFailedRedoStays() {
|
||||
let log = StepLog()
|
||||
let provider = NativeHistoryProvider()
|
||||
provider.register(log.step("Move Card", undo: .applied, redo: .failed))
|
||||
|
||||
provider.undo()
|
||||
provider.redo()
|
||||
|
||||
#expect(provider.canRedo, "still there to retry")
|
||||
#expect(provider.redoActionName == "Move Card")
|
||||
#expect(provider.canUndo == false)
|
||||
}
|
||||
|
||||
@Test("A step is told which command it is being crossed by, not which half is running")
|
||||
func stepsAreToldTheDirection() {
|
||||
let log = StepLog()
|
||||
let provider = NativeHistoryProvider()
|
||||
provider.register(log.step("Rename Card"))
|
||||
|
||||
provider.undo()
|
||||
provider.redo()
|
||||
provider.undo()
|
||||
|
||||
// The third crossing runs the *undo* half again, and the second runs the half registered as
|
||||
// `redo` — what each is told is ⌘Z, ⇧⌘Z, ⌘Z, which is what the skip banner has to say.
|
||||
#expect(log.directions == [.undo, .redo, .undo])
|
||||
}
|
||||
|
||||
@Test("A stack of nothing but stale steps empties itself and stops")
|
||||
func anEntirelyStaleStackEmptiesItself() {
|
||||
let log = StepLog()
|
||||
@@ -283,6 +342,26 @@ struct BoardUndoManagerTests {
|
||||
#expect(manager.redoMenuItemTitle == "Redo Rename Lane")
|
||||
}
|
||||
|
||||
@Test("A read-only board disables both directions, whatever the stack holds")
|
||||
func theLockDisablesEnablement() {
|
||||
final class Lock { var isOn = false }
|
||||
let lock = Lock()
|
||||
let provider = FakeHistoryProvider()
|
||||
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
|
||||
provider.canUndo = true
|
||||
provider.canRedo = true
|
||||
#expect(manager.canUndo)
|
||||
|
||||
lock.isOn = true
|
||||
|
||||
#expect(manager.canUndo == false, "disabled with every other mutating command")
|
||||
#expect(manager.canRedo == false)
|
||||
#expect(provider.canUndo, "an enablement answer, not a clearing — the stack survives")
|
||||
|
||||
lock.isOn = false
|
||||
#expect(manager.canUndo, "and resumes when the lock clears")
|
||||
}
|
||||
|
||||
@Test("Crossing forwards to the provider — what ⌘Z and the toolbar item actually reach")
|
||||
func crossingForwards() {
|
||||
let provider = FakeHistoryProvider()
|
||||
@@ -476,6 +555,27 @@ struct BoardSessionHistoryTests {
|
||||
#expect(log.crossings.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The session's manager answers this board's own read-only lock")
|
||||
func theSessionWiresTheLock() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
session.history.register(StepLog().step("Move Card"))
|
||||
#expect(session.undoManager.canUndo)
|
||||
|
||||
session.store.enterVanishedRootLock()
|
||||
#expect(session.undoManager.canUndo == false)
|
||||
#expect(session.history.canUndo, "the stack itself survives the lock")
|
||||
|
||||
session.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await session.store.awaitQuiescence()
|
||||
#expect(session.undoManager.canUndo, "and resumes when it clears")
|
||||
}
|
||||
|
||||
@Test("The composition root decides which provider a session gets")
|
||||
func theProviderIsBoundAtComposition() throws {
|
||||
let fixture = try makeBoard()
|
||||
|
||||
@@ -775,6 +775,477 @@ struct CrossingIsAWriteTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Staleness
|
||||
|
||||
/// A writer that is not the app: `BoardWriter` reached **around** the store, which is exactly what an
|
||||
/// agent, a hand edit or another editor is from the stack's point of view — a change no step was
|
||||
/// registered for (13-native-undo.md ▸ Rules: "Foreign writes never join the stack ... collisions are
|
||||
/// handled lazily, per step, by validation").
|
||||
private enum Foreign {
|
||||
|
||||
static func rename(_ fixture: WriterFixture, _ path: String, to title: String) throws {
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .rename(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string(title))
|
||||
}
|
||||
}
|
||||
|
||||
static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws {
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.background, to: .string(background))
|
||||
}
|
||||
}
|
||||
|
||||
static func setOrder(_ fixture: WriterFixture, _ path: String, to order: Double) throws {
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .reorder(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(order))
|
||||
}
|
||||
}
|
||||
|
||||
static func setBody(_ fixture: WriterFixture, _ path: String, to body: String) throws {
|
||||
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body)
|
||||
}
|
||||
|
||||
static func delete(_ fixture: WriterFixture, _ path: String) throws {
|
||||
try BoardWriter.deleteItem(at: fixture.url(path))
|
||||
}
|
||||
|
||||
static func purge(_ fixture: WriterFixture, _ path: String) throws {
|
||||
try BoardWriter.purgeItem(at: fixture.url(path))
|
||||
}
|
||||
|
||||
static func move(_ fixture: WriterFixture, _ path: String, toLane lane: String, order: Double) throws {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: fixture.url(path),
|
||||
toParent: fixture.url(lane),
|
||||
sourceBoardRoot: fixture.root,
|
||||
destinationBoardRoot: fixture.root,
|
||||
order: order
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The staleness predicate at ⌘Z time (13-native-undo.md ▸ Rules ▸ staleness validation): "an inverse
|
||||
/// operation re-checks its target against the current snapshot at ⌘Z time ... Target folder gone, or
|
||||
/// the field no longer holding the step's after-value → the step is **skipped, not applied**: popped
|
||||
/// from the stack with an info-tone banner ... and ⌘Z falls through to the next step."
|
||||
///
|
||||
/// Every test here is the same hostile shape: perform a gesture, let somebody else write to the board
|
||||
/// behind the app's back, then press ⌘Z and read the **file**. What must never happen is the inverse
|
||||
/// landing on top of the foreign write.
|
||||
@MainActor
|
||||
@Suite("Undo ▸ staleness")
|
||||
struct StaleStepTests {
|
||||
|
||||
// MARK: Existence and liveness
|
||||
|
||||
@Test("A foreign delete of the target skips its step — and ⌘Z falls through to the next one")
|
||||
func aForeignDeleteSkipsAndFallsThrough() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
store.transient.beginRename(of: card2, currentTitle: "Second")
|
||||
store.transient.updateRenameDraft("Second!")
|
||||
store.commitRename()
|
||||
|
||||
try Foreign.delete(fixture, card2Path)
|
||||
history.undo()
|
||||
|
||||
// The top step's card is in the trash now, so its rename is not ours to walk back; the one
|
||||
// below it is untouched and applies in the same ⌘Z.
|
||||
#expect(try document(fixture, card2Path).title.value == "Second!", "the foreign writer's board, left alone")
|
||||
#expect(try document(fixture, card1Path).title.value == "First", "⌘Z fell through and did something")
|
||||
#expect(store.banners.signposts.map(\.message)
|
||||
== ["Undo skipped — 'Second!' changed outside Lanework"])
|
||||
#expect(history.canUndo == false, "both steps were consumed — one skipped, one applied")
|
||||
#expect(history.redoActionName == "Rename Card", "only the step that ran is redoable")
|
||||
}
|
||||
|
||||
@Test("A target the foreign writer removed outright skips rather than failing")
|
||||
func aVanishedTargetSkips() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.delete([card1])
|
||||
try Foreign.purge(fixture, card1Path)
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(fixture.exists(card1Path) == false)
|
||||
#expect(store.banners.signposts.count == 1)
|
||||
#expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row")
|
||||
#expect(history.canUndo == false)
|
||||
#expect(history.canRedo == false, "a skipped step leaves nothing behind")
|
||||
}
|
||||
|
||||
@Test("A foreign Put Back skips the delete's undo — the item is not on the side we left it")
|
||||
func aForeignRestoreSkipsTheDeleteStep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.delete([card1])
|
||||
try BoardWriter.restoreItem(at: fixture.url(card1Path))
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing, "still live, as they left it")
|
||||
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"])
|
||||
}
|
||||
|
||||
@Test("A card a foreign writer moved away leaves nothing at the destination to walk back")
|
||||
func aForeignMoveSkipsTheMoveStep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.moveCards([card1], toLane: lane2, at: 0)
|
||||
try Foreign.move(fixture, "\(Ident.lane2)/\(Ident.card1)", toLane: Ident.lane1, order: 5000)
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(fixture.exists(card1Path), "where the foreign writer put it")
|
||||
#expect(try document(fixture, card1Path).order.value == 5000, "at the rank they gave it, not ours")
|
||||
#expect(store.banners.signposts.count == 1)
|
||||
#expect(history.canUndo == false)
|
||||
}
|
||||
|
||||
@Test("A tombstoned card is stale for a field edit, even with the field itself untouched")
|
||||
func aTombstonedTargetIsStaleForAFieldEdit() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.setLaneWidth(lane1, units: 3)
|
||||
try Foreign.delete(fixture, Ident.lane1)
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, Ident.lane1).width.value == 3, "not resized inside the trash")
|
||||
#expect(store.banners.signposts.count == 1)
|
||||
}
|
||||
|
||||
@Test("A card under a foreign-tombstoned lane is stale too — liveness is ancestor-walked")
|
||||
func anAncestorTombstoneIsStale() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
try Foreign.delete(fixture, Ident.lane1)
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "Renamed", "the card renders nowhere; nothing was written")
|
||||
#expect(store.banners.signposts.count == 1)
|
||||
}
|
||||
|
||||
// MARK: The field-level predicate
|
||||
|
||||
@Test("A foreign edit of the very field the step wrote skips it")
|
||||
func aForeignFieldEditSkips() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
|
||||
try Foreign.rename(fixture, card1Path, to: "Theirs")
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "Theirs",
|
||||
"never apply a stale inverse on top of someone else's newer write")
|
||||
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Renamed' changed outside Lanework"])
|
||||
#expect(history.canUndo == false)
|
||||
#expect(history.canRedo == false)
|
||||
}
|
||||
|
||||
@Test("A foreign change to an unrelated item skips nothing — the predicate is per target")
|
||||
func anUnrelatedForeignChangeAppliesNormally() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
|
||||
try Foreign.rename(fixture, card2Path, to: "Theirs")
|
||||
try Foreign.delete(fixture, card3Path)
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "First", "the step applied")
|
||||
#expect(try document(fixture, card2Path).title.value == "Theirs", "and left the neighbours alone")
|
||||
#expect(store.banners.signposts.isEmpty, "nothing to explain")
|
||||
#expect(history.redoActionName == "Rename Card")
|
||||
}
|
||||
|
||||
@Test("A foreign change to another field of the same item skips nothing either")
|
||||
func anUnrelatedFieldOfTheSameItemApplies() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
|
||||
try Foreign.restyle(fixture, card1Path, background: "red")
|
||||
history.undo()
|
||||
|
||||
let undone = try document(fixture, card1Path)
|
||||
#expect(undone.title.value == "First", "the rename walked back")
|
||||
#expect(undone.background.value == "red", "their colour survived it")
|
||||
#expect(store.banners.signposts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A foreign rank change skips a reorder")
|
||||
func aForeignRankSkipsAReorder() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.moveLane(lane1, toIndex: 1)
|
||||
try Foreign.setOrder(fixture, Ident.lane1, to: 9000)
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, Ident.lane1).order.value == 9000)
|
||||
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Todo' changed outside Lanework"])
|
||||
}
|
||||
|
||||
@Test("A foreign body edit skips the Edit session's step — body steps compare bytes")
|
||||
func aForeignBodyEditSkipsTheSessionStep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body
|
||||
|
||||
let session = CardBodyEditSession()
|
||||
session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished }
|
||||
session.registerUndo = { [weak store] prior, new in
|
||||
store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new)
|
||||
}
|
||||
session.adopt(diskBody: original)
|
||||
session.edited("Mine.\n")
|
||||
_ = session.flush()
|
||||
session.endEditSession()
|
||||
#expect(history.undoActionName == "Edit Card")
|
||||
|
||||
// One character of difference is a different body: the step wrote every byte of the span.
|
||||
try Foreign.setBody(fixture, card1Path, to: "Mine.\nAnd theirs.\n")
|
||||
history.undo()
|
||||
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "Mine.\nAnd theirs.\n")
|
||||
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"])
|
||||
}
|
||||
|
||||
// MARK: The banner
|
||||
|
||||
@Test("A batch names the step, since there is no single item to name")
|
||||
func aBatchStepNamesItself() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyStyle(to: .items([card1, card2, card3]), background: .set("red"))
|
||||
try Foreign.restyle(fixture, card2Path, background: "green")
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(store.banners.signposts.map(\.message)
|
||||
== ["Undo skipped — 'Restyle 3 Cards' changed outside Lanework"])
|
||||
#expect(try document(fixture, card1Path).background.value == "red", "all or nothing: no half-applied batch")
|
||||
}
|
||||
|
||||
@Test("The skip row is an info-tone signpost — dismissable, and never an error")
|
||||
func theSkipRowIsASignpost() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.delete([card1])
|
||||
try Foreign.purge(fixture, card1Path)
|
||||
history.undo()
|
||||
|
||||
let row = try #require(store.bannerRows.last)
|
||||
#expect(row.tone == .info)
|
||||
#expect(row.dismissID != nil, "one-shot lifecycle: the user clears it, nothing expires it")
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
#expect(store.banners.losses.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Redo
|
||||
|
||||
@Test("Redo validates the same way, and says so in its own verb")
|
||||
func redoStalenessIsSymmetric() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
history.undo()
|
||||
#expect(try document(fixture, card1Path).title.value == "First")
|
||||
|
||||
// Somebody writes over the state the undo left, so the *forward* write is now the stale one.
|
||||
try Foreign.rename(fixture, card1Path, to: "Theirs")
|
||||
history.redo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "Theirs")
|
||||
#expect(store.banners.signposts.map(\.message)
|
||||
== ["Redo skipped — 'Renamed' changed outside Lanework"])
|
||||
#expect(history.canRedo == false)
|
||||
}
|
||||
|
||||
@Test("An undone create redoes only onto the hole it left")
|
||||
func redoOfACreateChecksTheHole() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let before = try fixture.entryNames("")
|
||||
|
||||
store.createLane()
|
||||
let created = try #require(try fixture.entryNames("").first { !before.contains($0) })
|
||||
history.undo()
|
||||
#expect(fixture.exists(created) == false)
|
||||
|
||||
// Somebody puts a folder back at that identity — the redo's `recreateItem` refuses to
|
||||
// clobber, so validation is what turns that into a skip rather than a write failure.
|
||||
try fixture.item(created, Item.rich(order: "4096", title: "Theirs"))
|
||||
history.redo()
|
||||
|
||||
#expect(try document(fixture, created).title.value == "Theirs")
|
||||
#expect(store.banners.signposts.count == 1)
|
||||
#expect(store.banners.oneShots.isEmpty, "skipped before the Writer was ever reached")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stale versus failed
|
||||
|
||||
/// The distinction 13 leaves to the implementation and this milestone settles: a **stale** step is
|
||||
/// one the board has moved past — dropped, explained by the info row, ⌘Z falls through. A **failed**
|
||||
/// one is a step the user still means to cross, refused by a condition that is usually momentary —
|
||||
/// so it stays put, the ordinary write-failure error row says why, and ⌘Z retries it.
|
||||
@MainActor
|
||||
@Suite("Undo ▸ stale versus failed")
|
||||
struct FailedCrossingTests {
|
||||
|
||||
@Test("An inverse that cannot be written keeps its step, and banners as a write failure")
|
||||
func aFailedInverseKeepsItsStep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
|
||||
// The read and the parse succeed — so validation passes, and the step is genuinely current —
|
||||
// and then the atomic replace has nowhere to land its temp file.
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "Renamed", "nothing landed")
|
||||
#expect(store.banners.oneShots.count == 1, "the ordinary write-failure row, not a skip")
|
||||
#expect(store.banners.signposts.isEmpty)
|
||||
#expect(history.canUndo, "the step stays: a full disk is not a reason to lose the way back")
|
||||
#expect(history.undoActionName == "Rename Card")
|
||||
#expect(history.canRedo == false)
|
||||
|
||||
// And when the condition clears, the same ⌘Z works.
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.url(card1Path).path)
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "First")
|
||||
#expect(history.canRedo)
|
||||
}
|
||||
|
||||
@Test("A failed crossing stops rather than falling through to the steps below it")
|
||||
func aFailedCrossingDoesNotFallThrough() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.delete([card2])
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card2Path).deleted.value != nil,
|
||||
"the step below was never reached — a refused disk is not a reason to attempt more")
|
||||
#expect(history.undoActionName == "Rename Card")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The read-only lock
|
||||
|
||||
/// "Every read-only lock ... disables Undo/Redo with the other mutating commands; the stack itself
|
||||
/// survives the lock and resumes when it clears" (13-native-undo.md ▸ Rules).
|
||||
@MainActor
|
||||
@Suite("Undo ▸ the read-only lock")
|
||||
struct LockedBoardUndoTests {
|
||||
|
||||
@Test("A locked board disables Undo and Redo — and the stack is still there when it clears")
|
||||
func theLockDisablesAndTheStackSurvives() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let manager = BoardUndoManager(history: history, isReadOnly: { [weak store] in store?.isReadOnly ?? false })
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
#expect(manager.canUndo)
|
||||
|
||||
store.enterVanishedRootLock()
|
||||
|
||||
#expect(manager.canUndo == false, "disabled with every other mutating command")
|
||||
#expect(manager.canRedo == false)
|
||||
#expect(history.canUndo, "the stack itself survives the lock")
|
||||
#expect(manager.undoMenuItemTitle == "Undo Rename Card", "a disabled row keeps its name")
|
||||
|
||||
// The lock clears on the next successful reload, and the same ⌘Z crosses the same step.
|
||||
await reload(store)
|
||||
#expect(store.isReadOnly == false)
|
||||
#expect(manager.canUndo)
|
||||
|
||||
manager.undo()
|
||||
#expect(try document(fixture, card1Path).title.value == "First")
|
||||
}
|
||||
|
||||
@Test("A crossing that starts anyway is refused without losing the step")
|
||||
func aCrossingUnderTheLockLosesNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
store.enterVanishedRootLock()
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try document(fixture, card1Path).title.value == "Renamed", "the lock refused the write")
|
||||
#expect(history.canUndo, "a refusal is a failure, not a staleness — the step stays")
|
||||
#expect(store.banners.signposts.isEmpty, "the standing lock row is the message")
|
||||
#expect(store.banners.oneShots.isEmpty, "and a refused write posts nothing of its own")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The phrase vocabulary
|
||||
|
||||
@Suite("Undo ▸ the phrase vocabulary")
|
||||
|
||||
Reference in New Issue
Block a user