Files
lanework/KanbanTests/HistoryProviderTests.swift
T
rzen 93fad2ef1e 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
2026-07-28 13:47:27 -04:00

497 lines
18 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] = []
/// 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")
}
}