Build the HistoryProviding seam and the per-board native undo stack
The provider seam 12 promised: HistoryProviding speaks 13's vocabulary — register a HistoryStep (bare 06 phrase plus undo/redo closures returning applied or skipped), canUndo/canRedo, action names, clear — and no UndoManager type appears anywhere in it, proven by a fake that satisfies the seam with counters. The base provider wraps a private UndoManager with groupsByEvent off so coalescing stays the Writer call site's decision; undo re-registers the reversed step from inside the undo, which makes a stale-skipped step vanish for free and the crossing loop fall through to the next. BoardUndoManager adapts the protocol to the responder chain — a stackless UndoManager subclass answering from the provider — so Pro's git provider inherits menu enablement, dynamic titles, and the nil-target toolbar pair by binding the protocol. One stack per board session, born in beginSession, cleared in the close flush; every window over the board answers it through windowWillReturnUndoManager. Headless probes shaped the routing: a real NSTextView's own manager wins natively, but a field editor's does not — BoardUndoRouting answers the per-window text manager while any NSText is first responder, so a search-field typo never crosses a board step. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
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] = []
|
||||
|
||||
/// 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.
|
||||
func staleStep(_ name: String) -> HistoryStep {
|
||||
step(name, undo: .skipped, redo: .applied)
|
||||
}
|
||||
|
||||
func step(_ name: String, undo: HistoryStepOutcome, redo: HistoryStepOutcome) -> HistoryStep {
|
||||
HistoryStep(
|
||||
name: name,
|
||||
undo: { [weak self] in
|
||||
self?.crossings.append("undo \(name)")
|
||||
return undo
|
||||
},
|
||||
redo: { [weak self] in
|
||||
self?.crossings.append("redo \(name)")
|
||||
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 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("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 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user