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
597 lines
22 KiB
Swift
597 lines
22 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The provider seam and the board stack behind it (12-editions.md ▸ The provider seam;
|
|
/// 13-native-undo.md).
|
|
///
|
|
/// The steps here are **synthetic** on purpose: what this milestone builds is the stack and the
|
|
/// seam, and the real inverses arrive at the Writer boundary in the next one. A step that records
|
|
/// its own crossing is therefore the exact fixture — it proves the stack's grammar (one gesture one
|
|
/// step, undo flips to redo, a stale step falls through) without needing a board to move cards
|
|
/// around on.
|
|
|
|
// MARK: - Synthetic steps
|
|
|
|
/// Records every crossing, in order, and answers with whatever outcome the test asked for.
|
|
@MainActor
|
|
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 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] direction in
|
|
self?.crossings.append("undo \(name)")
|
|
self?.directions.append(direction)
|
|
return undo
|
|
},
|
|
redo: { [weak self] direction in
|
|
self?.crossings.append("redo \(name)")
|
|
self?.directions.append(direction)
|
|
return redo
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - The native stack
|
|
|
|
@MainActor
|
|
@Suite("History ▸ the native stack")
|
|
struct NativeHistoryProviderTests {
|
|
|
|
@Test("A fresh provider has nothing to cross and nothing to say about it")
|
|
func emptyStack() {
|
|
let provider = NativeHistoryProvider()
|
|
#expect(provider.canUndo == false)
|
|
#expect(provider.canRedo == false)
|
|
#expect(provider.undoActionName == nil)
|
|
#expect(provider.redoActionName == nil)
|
|
}
|
|
|
|
@Test("Registering a step arms Undo and names it — and runs nothing")
|
|
func registerArmsUndo() {
|
|
let log = StepLog()
|
|
let provider = NativeHistoryProvider()
|
|
|
|
provider.register(log.step("Move Card"))
|
|
|
|
#expect(provider.canUndo)
|
|
#expect(provider.canRedo == false)
|
|
#expect(provider.undoActionName == "Move Card")
|
|
#expect(provider.redoActionName == nil)
|
|
#expect(log.crossings.isEmpty, "registration is not application")
|
|
}
|
|
|
|
@Test("Undo runs the inverse once and flips the step onto Redo, keeping its name")
|
|
func undoRunsTheInverseAndFlipsToRedo() {
|
|
let log = StepLog()
|
|
let provider = NativeHistoryProvider()
|
|
provider.register(log.step("Move 3 Cards"))
|
|
|
|
provider.undo()
|
|
|
|
#expect(log.crossings == ["undo Move 3 Cards"])
|
|
#expect(provider.canUndo == false)
|
|
#expect(provider.canRedo)
|
|
// The phrase names the gesture, not the direction — "Undo Move 3 Cards" becomes
|
|
// "Redo Move 3 Cards".
|
|
#expect(provider.redoActionName == "Move 3 Cards")
|
|
#expect(provider.undoActionName == nil)
|
|
}
|
|
|
|
@Test("Redo replays the write and arms Undo again — the classic dance, both ways")
|
|
func redoReplaysAndFlipsBack() {
|
|
let log = StepLog()
|
|
let provider = NativeHistoryProvider()
|
|
provider.register(log.step("Rename Lane"))
|
|
|
|
provider.undo()
|
|
provider.redo()
|
|
|
|
#expect(log.crossings == ["undo Rename Lane", "redo Rename Lane"])
|
|
#expect(provider.canUndo)
|
|
#expect(provider.canRedo == false)
|
|
#expect(provider.undoActionName == "Rename Lane")
|
|
|
|
provider.undo()
|
|
#expect(log.crossings == ["undo Rename Lane", "redo Rename Lane", "undo Rename Lane"])
|
|
}
|
|
|
|
@Test("One register call is one step — two registrations are two crossings, newest first")
|
|
func oneRegistrationIsOneStep() {
|
|
let log = StepLog()
|
|
let provider = NativeHistoryProvider()
|
|
// Back to back, in one turn of the run loop: `groupsByEvent` must not fold these into one.
|
|
provider.register(log.step("Move Card"))
|
|
provider.register(log.step("Rename Card"))
|
|
|
|
#expect(provider.undoActionName == "Rename Card")
|
|
provider.undo()
|
|
#expect(log.crossings == ["undo Rename Card"])
|
|
#expect(provider.undoActionName == "Move Card")
|
|
|
|
provider.undo()
|
|
#expect(log.crossings == ["undo Rename Card", "undo Move Card"])
|
|
#expect(provider.canUndo == false)
|
|
}
|
|
|
|
@Test("A new step clears the redo stack — classic behaviour")
|
|
func registeringClearsRedo() {
|
|
let log = StepLog()
|
|
let provider = NativeHistoryProvider()
|
|
provider.register(log.step("Move Card"))
|
|
provider.undo()
|
|
#expect(provider.canRedo)
|
|
|
|
provider.register(log.step("Delete Card"))
|
|
|
|
#expect(provider.canRedo == false)
|
|
#expect(provider.redoActionName == nil)
|
|
#expect(provider.undoActionName == "Delete Card")
|
|
}
|
|
|
|
@Test("Undo on an empty stack does nothing at all")
|
|
func undoOnAnEmptyStackIsInert() {
|
|
let log = StepLog()
|
|
let provider = NativeHistoryProvider()
|
|
|
|
provider.undo()
|
|
provider.redo()
|
|
|
|
#expect(log.crossings.isEmpty)
|
|
#expect(provider.canUndo == false)
|
|
#expect(provider.canRedo == false)
|
|
}
|
|
|
|
@Test("A stale step is skipped, not applied — and ⌘Z falls through to the next one")
|
|
func aStaleStepIsSkippedAndFallsThrough() {
|
|
let log = StepLog()
|
|
let provider = NativeHistoryProvider()
|
|
provider.register(log.step("Move Card"))
|
|
provider.register(log.staleStep("Rename Card"))
|
|
|
|
provider.undo()
|
|
|
|
// Both were reached in one ⌘Z: the stale one declined and was dropped, the next one applied.
|
|
#expect(log.crossings == ["undo Rename Card", "undo Move Card"])
|
|
#expect(provider.canUndo == false)
|
|
// Only the step that actually ran is redoable — a skipped step leaves nothing behind.
|
|
#expect(provider.canRedo)
|
|
#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()
|
|
let provider = NativeHistoryProvider()
|
|
provider.register(log.staleStep("Move Card"))
|
|
provider.register(log.staleStep("Rename Card"))
|
|
|
|
provider.undo()
|
|
|
|
#expect(log.crossings == ["undo Rename Card", "undo Move Card"])
|
|
#expect(provider.canUndo == false)
|
|
#expect(provider.canRedo == false)
|
|
}
|
|
|
|
@Test("Clearing drops both directions — the session-only rule's one call")
|
|
func clearEmptiesBothStacks() {
|
|
let log = StepLog()
|
|
let provider = NativeHistoryProvider()
|
|
provider.register(log.step("Move Card"))
|
|
provider.undo()
|
|
provider.register(log.step("Delete Card"))
|
|
|
|
provider.clear()
|
|
|
|
#expect(provider.canUndo == false)
|
|
#expect(provider.canRedo == false)
|
|
#expect(provider.undoActionName == nil)
|
|
#expect(provider.redoActionName == nil)
|
|
provider.undo()
|
|
#expect(log.crossings == ["undo Move Card"], "nothing crossed after the clear")
|
|
}
|
|
|
|
@Test("Two providers are two stacks — undo is board-local by construction")
|
|
func providersAreIndependent() {
|
|
let log = StepLog()
|
|
let one = NativeHistoryProvider()
|
|
let other = NativeHistoryProvider()
|
|
|
|
one.register(log.step("Move Card"))
|
|
|
|
#expect(one.canUndo)
|
|
#expect(other.canUndo == false)
|
|
|
|
other.undo()
|
|
#expect(log.crossings.isEmpty, "the other board's ⌘Z crosses nothing of this board's")
|
|
#expect(one.canUndo)
|
|
}
|
|
}
|
|
|
|
// MARK: - The AppKit adapter
|
|
|
|
/// A provider with no `NSUndoManager` anywhere in it — which is the point: `BoardUndoManager` is
|
|
/// tested against *this* rather than against the native stack, because what has to be true is that
|
|
/// the adapter works for any implementation of the seam (Pro's git provider binds the same protocol
|
|
/// in pro-m1).
|
|
@MainActor
|
|
private final class FakeHistoryProvider: HistoryProviding {
|
|
|
|
var canUndo = false
|
|
var canRedo = false
|
|
var undoActionName: String?
|
|
var redoActionName: String?
|
|
private(set) var registered: [String] = []
|
|
private(set) var undoCount = 0
|
|
private(set) var redoCount = 0
|
|
private(set) var clearCount = 0
|
|
|
|
func register(_ step: HistoryStep) { registered.append(step.name) }
|
|
func undo() { undoCount += 1 }
|
|
func redo() { redoCount += 1 }
|
|
func clear() { clearCount += 1 }
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("History ▸ the AppKit adapter")
|
|
struct BoardUndoManagerTests {
|
|
|
|
@Test("Enablement is the provider's answer, not a stack of the adapter's own")
|
|
func enablementMirrorsTheProvider() {
|
|
let provider = FakeHistoryProvider()
|
|
let manager = BoardUndoManager(history: provider)
|
|
|
|
#expect(manager.canUndo == false)
|
|
#expect(manager.canRedo == false)
|
|
|
|
provider.canUndo = true
|
|
provider.canRedo = true
|
|
|
|
#expect(manager.canUndo)
|
|
#expect(manager.canRedo)
|
|
}
|
|
|
|
@Test("The menu titles are the platform's composition over the step's own phrase")
|
|
func menuTitlesComposeFromTheStepName() {
|
|
let provider = FakeHistoryProvider()
|
|
let manager = BoardUndoManager(history: provider)
|
|
|
|
// Nothing to cross: the bare verb, with no trailing space where the name would go.
|
|
#expect(manager.undoMenuItemTitle == "Undo")
|
|
#expect(manager.redoMenuItemTitle == "Redo")
|
|
|
|
provider.canUndo = true
|
|
provider.undoActionName = "Move 3 Cards"
|
|
provider.canRedo = true
|
|
provider.redoActionName = "Rename Lane"
|
|
|
|
#expect(manager.undoActionName == "Move 3 Cards")
|
|
#expect(manager.undoMenuItemTitle == "Undo Move 3 Cards")
|
|
#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()
|
|
let manager = BoardUndoManager(history: provider)
|
|
|
|
manager.undo()
|
|
manager.undo()
|
|
manager.redo()
|
|
|
|
#expect(provider.undoCount == 2)
|
|
#expect(provider.redoCount == 1)
|
|
}
|
|
|
|
@Test("A stray registration into the adapter can never be crossed or shown")
|
|
func theAdaptersOwnStackStaysInert() {
|
|
let provider = FakeHistoryProvider()
|
|
let manager = BoardUndoManager(history: provider)
|
|
let sink = FakeHistoryProvider()
|
|
|
|
manager.registerUndo(withTarget: sink) { $0.canUndo = true }
|
|
|
|
#expect(manager.canUndo == false, "the provider is the only source of truth")
|
|
#expect(manager.undoMenuItemTitle == "Undo")
|
|
manager.undo()
|
|
#expect(sink.canUndo == false, "the stray action was never run")
|
|
#expect(provider.undoCount == 1)
|
|
}
|
|
|
|
@Test("Clearing the adapter's own stack leaves the board's alone")
|
|
func removeAllActionsDoesNotClearTheBoard() {
|
|
let provider = FakeHistoryProvider()
|
|
let manager = BoardUndoManager(history: provider)
|
|
provider.canUndo = true
|
|
|
|
manager.removeAllActions()
|
|
|
|
#expect(provider.clearCount == 0)
|
|
#expect(manager.canUndo)
|
|
}
|
|
}
|
|
|
|
// MARK: - Routing
|
|
|
|
@MainActor
|
|
@Suite("History ▸ undo routing")
|
|
struct BoardUndoRoutingTests {
|
|
|
|
@Test("A text view — field editors included — is a text-editing surface; a plain view is not")
|
|
func textEditingClassification() {
|
|
#expect(BoardUndoRouting.isTextEditing(NSTextView()))
|
|
#expect(BoardUndoRouting.isTextEditing(NSTextField()) == false, "focused, not yet editing")
|
|
#expect(BoardUndoRouting.isTextEditing(NSView()) == false)
|
|
#expect(BoardUndoRouting.isTextEditing(NSWindow()) == false)
|
|
#expect(BoardUndoRouting.isTextEditing(nil) == false)
|
|
}
|
|
|
|
@Test("With focus outside every text surface, a board window answers with the board's stack")
|
|
func boardFocusAnswersTheBoardStack() {
|
|
let board = BoardUndoManager(history: FakeHistoryProvider())
|
|
let fallback = UndoManager()
|
|
|
|
let answer = BoardUndoRouting.undoManager(isTextEditing: false, board: board, textFallback: fallback)
|
|
|
|
#expect(answer === board)
|
|
}
|
|
|
|
@Test("A focused field editor answers with the window's text manager, never the board's")
|
|
func fieldEditorFocusAnswersTheTextManager() {
|
|
let board = BoardUndoManager(history: FakeHistoryProvider())
|
|
let fallback = UndoManager()
|
|
|
|
let answer = BoardUndoRouting.undoManager(isTextEditing: true, board: board, textFallback: fallback)
|
|
|
|
#expect(answer === fallback)
|
|
}
|
|
|
|
@Test("A window with no board answers with the text manager either way")
|
|
func windowsWithNoBoardFallBack() {
|
|
let fallback = UndoManager()
|
|
|
|
#expect(BoardUndoRouting.undoManager(isTextEditing: false, board: nil, textFallback: fallback) === fallback)
|
|
#expect(BoardUndoRouting.undoManager(isTextEditing: true, board: nil, textFallback: fallback) === fallback)
|
|
}
|
|
}
|
|
|
|
// MARK: - The session that owns the stack
|
|
|
|
/// A board on disk, and an `AppModel` whose registry file is in temp rather than in the test host's
|
|
/// real Application Support directory — `AppModelTests`' two helpers, in the shape this suite needs.
|
|
@MainActor
|
|
private func makeBoard() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
return fixture
|
|
}
|
|
|
|
@MainActor
|
|
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
|
let folder = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("HistoryProviderTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
let model = AppModel(registryStorageURL: folder.appendingPathComponent("board-registry.json"))
|
|
return (model, { try? FileManager.default.removeItem(at: folder) })
|
|
}
|
|
|
|
@MainActor
|
|
@discardableResult
|
|
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
|
|
let ref = BoardWindowRef(url: url)
|
|
let recordID = model.boardRegistry.recordOpen(of: url)
|
|
let store = try model.storeRegistry.acquire(url)
|
|
model.boardRegistry.setOpenNow(id: recordID)
|
|
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
|
return ref
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("History ▸ the board session's stack")
|
|
struct BoardSessionHistoryTests {
|
|
|
|
@Test("A session is born with a stack, and its adapter is a face for that same stack")
|
|
func aSessionOwnsOneStack() 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))
|
|
let log = StepLog()
|
|
|
|
#expect(session.undoManager.canUndo == false)
|
|
session.history.register(log.step("Move Card"))
|
|
|
|
// The window hands AppKit the adapter; the adapter is answering from the session's provider.
|
|
#expect(session.undoManager.canUndo)
|
|
#expect(session.undoManager.undoMenuItemTitle == "Undo Move Card")
|
|
|
|
session.undoManager.undo()
|
|
#expect(log.crossings == ["undo Move Card"])
|
|
#expect(session.undoManager.canRedo)
|
|
}
|
|
|
|
@Test("Two open boards are two stacks — never one another's")
|
|
func sessionsAreIsolated() throws {
|
|
let first = try makeBoard()
|
|
defer { first.tearDown() }
|
|
let second = try makeBoard()
|
|
defer { second.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
let firstRef = try openBoard(model, at: first.root)
|
|
let secondRef = try openBoard(model, at: second.root)
|
|
let log = StepLog()
|
|
|
|
let firstSession = try #require(model.session(for: firstRef))
|
|
let secondSession = try #require(model.session(for: secondRef))
|
|
#expect(firstSession.history !== secondSession.history)
|
|
|
|
firstSession.history.register(log.step("Move Card"))
|
|
|
|
#expect(firstSession.history.canUndo)
|
|
#expect(secondSession.history.canUndo == false)
|
|
|
|
secondSession.undoManager.undo()
|
|
#expect(log.crossings.isEmpty)
|
|
#expect(firstSession.history.canUndo, "the other board's ⌘Z left this one's stack alone")
|
|
}
|
|
|
|
@Test("Closing a board empties its stack — session-only persistence")
|
|
func closingClearsTheStack() 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))
|
|
let history = session.history
|
|
let log = StepLog()
|
|
history.register(log.step("Move Card"))
|
|
#expect(history.canUndo)
|
|
|
|
await model.closeBoard(ref: ref, cause: .userClose)
|
|
|
|
#expect(model.session(for: ref) == nil)
|
|
#expect(history.canUndo == false, "reopening the board starts empty")
|
|
#expect(history.canRedo == false)
|
|
#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()
|
|
defer { fixture.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
let bound = FakeHistoryProvider()
|
|
model.makeHistoryProvider = { _ in bound }
|
|
|
|
let ref = try openBoard(model, at: fixture.root)
|
|
let session = try #require(model.session(for: ref))
|
|
|
|
#expect(session.history === bound)
|
|
session.undoManager.undo()
|
|
#expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound")
|
|
}
|
|
}
|