Files
lanework/KanbanTests/HistoryProviderTests.swift
T
rzen 1fd19dfb12 The undo command surface rebuilds — app-owned rows and explicit toolbar targets over FocusedValues
Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar
pair takes explicit targets, both reading the focused session's
BoardUndoManager through FocusedValues.undoStack (board windows publish the
session's manager, card windows their own) — the nil-target route died with
the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface,
re-ruled 2026-08-08. The rows enact the routing predicate themselves: text
focus routes ⌘Z to the first responder's own manager, title and enablement
included, re-derived at fire time with a beep for the stale window.
NativeHistoryProvider turns @Observable so both surfaces re-derive on stack
changes; a checkpoint-notification ticker covers plain text managers.
.responderAction leaves ToolbarItemSpec with its only user;
windowWillReturnUndoManager stays wired for AppKit's own asks.

Live-probed on the fixture board (21/21): the row retitles to "Undo Add
Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server
chord, the toolbar pair validates and fires, search-field and body-editor
⌘Z stay text undo with board stacks untouched, and a card window crosses
its own stack with no fall-through. 2698 unit tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 18:55:34 -04:00

949 lines
39 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 (the git provider bound the same protocol,
/// in pro-m1, before app-managed git was excised entirely, `strategy/01-git-excision.md`).
@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"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
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 == true)
#expect(secondSession.history?.canUndo == false)
secondSession.undoManager.undo()
#expect(log.crossings.isEmpty)
#expect(firstSession.history?.canUndo == true, "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 = try #require(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 == true, "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")
}
}
// MARK: - The command surface
/// **What a window hands AppKit, and what the board toolbar's Undo/Redo pair reaches** — the two
/// halves of the command surface that are still platform-shaped, driven through the machinery they
/// ride on rather than described (03-board-ui.md ▸ Toolbar; 13-native-undo.md ▸ Rules).
///
/// ### What this suite covers since the surface became the app's own
///
/// **The rows are no longer here.** Edit ▸ Undo/Redo are `CommandGroup(replacing: .undoRedo)` rows
/// the app writes and routes itself (13 ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08;
/// `UndoCommands.swift`, pinned by `UndoCommandsTests.swift`), because the nil-target route they used
/// to ride is unreachable on a SwiftUI window: `NSWindow` latches an empty undo manager during
/// creation, before `HostedWindowController` installs, so `windowWillReturnUndoManager` is never
/// consulted for them. These tests never reproduced that — they attach the delegate before the
/// window's first read, which is exactly the ordering a real SwiftUI window denies — and that is the
/// diagnosis, not a gap to close: the hook works when it is asked, and the app stopped depending on
/// it being asked.
///
/// What is pinned below is therefore what remains true and load-bearing:
///
/// - **The delegate hook itself**, which stays wired because it is the right answer wherever *AppKit*
/// asks a window's delegate for a manager (`HostedWindowController.windowWillReturnUndoManager`).
/// `NSWindow.validateMenuItem(_:)` is the sharpest instrument a headless run has for reading what
/// that hook returned — enablement, the lock, the composed title, the two levels — so the menu
/// rows still appear here as the *probe*, not as the subject.
/// - **The toolbar pair's explicit target** (`BoardToolbar`), which validates and fires against the
/// session's `BoardUndoManager` directly. That path is the app's own end to end, and it is the one
/// a headless run can close completely.
@MainActor
@Suite("History ▸ the command surface")
struct UndoCommandSurfaceTests {
/// A scratch window fronted by the app's own delegate proxy, answering with `manager` — the
/// board window's wiring exactly (`BoardWindowHost` sets the same closure).
private func hostedWindow(_ manager: UndoManager?) -> (NSWindow, HostedWindowController) {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: true
)
let controller = HostedWindowController()
controller.windowUndoManager = { manager }
controller.attach(to: window)
return (window, controller)
}
/// A row carrying the platform's own `undo:`/`redo:`, used here as a **probe** rather than as a
/// shipped surface: validating one against the window is how a test reads back the manager
/// `windowWillReturnUndoManager` returned, title composition and all. The app's own rows carry no
/// selector at all (`UndoCommands.swift`).
private func menuItem(_ selector: String) -> NSMenuItem {
NSMenuItem(title: selector == "undo:" ? "Undo" : "Redo", action: NSSelectorFromString(selector), keyEquivalent: "")
}
// MARK: What the delegate hands back
@Test("A window's delegate hands back the board's stack, titles composing from its step names")
func theMenuRowsReadTheBoardsStack() {
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider)
let (window, controller) = hostedWindow(manager)
defer { controller.detach() }
let undoRow = menuItem("undo:")
let redoRow = menuItem("redo:")
#expect(window.undoManager === manager, "the window's answer is the session's adapter")
// Nothing to cross: both rows disabled, and titled with the bare verbs.
#expect(window.validateMenuItem(undoRow) == false)
#expect(window.validateMenuItem(redoRow) == false)
#expect(undoRow.title == "Undo")
#expect(redoRow.title == "Redo")
provider.canUndo = true
provider.undoActionName = "Move 3 Cards"
// 13's "the vocabulary supplies menu titles ('Undo Move 3 Cards'), via dynamic retitling":
// the app never writes that string — the platform composes it from the bare phrase the seam
// vends (`BoardUndoManager.undoMenuItemTitle`), and this is where it lands. The app's own
// rows read the very same property, one step further out (`UndoCommandRouting.undoTitle`).
#expect(window.validateMenuItem(undoRow))
#expect(undoRow.title == "Undo Move 3 Cards")
#expect(window.validateMenuItem(redoRow) == false)
#expect(redoRow.title == "Redo")
provider.canRedo = true
provider.redoActionName = "Rename Lane"
#expect(window.validateMenuItem(redoRow))
#expect(redoRow.title == "Redo Rename Lane")
// And it tracks the stack, rather than being set once: crossing a step renames the row.
provider.undoActionName = "Delete Card"
#expect(window.validateMenuItem(undoRow))
#expect(undoRow.title == "Undo Delete Card")
}
@Test("The read-only lock dims both rows and leaves their names standing")
func theLockDimsTheRowsWithoutRenamingThem() {
final class Lock { var isOn = false }
let lock = Lock()
let provider = FakeHistoryProvider()
provider.canUndo = true
provider.undoActionName = "Move Card"
provider.canRedo = true
provider.redoActionName = "Rename Card"
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
let (window, controller) = hostedWindow(manager)
defer { controller.detach() }
let undoRow = menuItem("undo:")
let redoRow = menuItem("redo:")
#expect(window.validateMenuItem(undoRow))
#expect(window.validateMenuItem(redoRow))
lock.isOn = true
#expect(window.validateMenuItem(undoRow) == false, "disabled with every other mutating command")
#expect(window.validateMenuItem(redoRow) == false)
#expect(undoRow.title == "Undo Move Card", "a disabled row keeps its name — the stack survives the lock")
#expect(redoRow.title == "Redo Rename Card")
lock.isOn = false
#expect(window.validateMenuItem(undoRow), "and resumes when it clears")
}
@Test("A window with no board has nothing to undo, and says so with the bare verb")
func aBoardlessWindowHasNothingToCross() {
let (window, controller) = hostedWindow(nil)
defer { controller.detach() }
let undoRow = menuItem("undo:")
#expect(window.validateMenuItem(undoRow) == false)
#expect(undoRow.title == "Undo")
}
@Test("A board window answers with the board's stack; a card window answers with its own")
func eachWindowAnswersWithItsOwnStack() throws {
// Realigned 2026-07-31 with the two-level model (13-native-undo.md ▸ Rules): this pinned the
// shared-stack wiring, which is exactly what the session-coarsening re-ruling replaced —
// "a card window owns its own stack ... and `window.undoManager` answers with it".
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))
// The board window and one of its card windows, wired the way their hosts wire them
// (`BoardWindowHost.configureWindow`, `CardWindowHost.configureWindow`).
let card = CardWindowUndo()
let (boardWindow, boardController) = hostedWindow(session.undoManager)
defer { boardController.detach() }
let (cardWindow, cardController) = hostedWindow(card.manager)
defer { cardController.detach() }
let boardRow = menuItem("undo:")
let cardRow = menuItem("undo:")
session.history?.register(StepLog().step("Move 3 Cards"))
#expect(boardWindow.validateMenuItem(boardRow))
#expect(boardRow.title == "Undo Move 3 Cards")
// **No fall-through** (13-native-undo.md ▸ Undo routing): the card window's own stack is
// empty, so its row is disabled and ⌘Z beeps — it never reaches the board's step. The rows
// enforce it the same way now, by reading one focused value that is one window's or the
// other's (`FocusedValues.undoStack`) and never both.
#expect(cardWindow.validateMenuItem(cardRow) == false)
#expect(cardRow.title == "Undo")
card.stack.register(StepLog().step("Comment"))
#expect(cardWindow.validateMenuItem(cardRow))
#expect(cardRow.title == "Undo Comment")
#expect(boardRow.title == "Undo Move 3 Cards", "and the board's row is untouched by it")
}
// MARK: The toolbar twins
/// A board toolbar wired the way `BoardWindowHost` wires one, over `undo`.
private func boardToolbar(store: BoardStore, undo: BoardUndoManager?) -> WindowToolbarController {
let domain = "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)"
return BoardToolbar.controller(
store: store,
search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: domain)!),
appearance: AppearanceStore(defaults: UserDefaults(suiteName: domain + ".appearance")!, apply: { _ in }),
session: DragSession(),
undo: undo
)
}
/// One real item, built by the real delegate.
private func toolbarItem(
_ controller: WindowToolbarController,
_ identifier: NSToolbarItem.Identifier
) throws -> NSToolbarItem {
try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: identifier,
willBeInsertedIntoToolbar: true
))
}
/// **The pair carries an explicit target now** (13-native-undo.md ▸ Rules ▸ the command-surface
/// bullet, re-ruled 2026-08-08) — the toolbar controller, over the session's `BoardUndoManager`,
/// where until that ruling both items carried nil targets and `undo:`/`redo:` selectors for the
/// responder chain to resolve.
///
/// This replaces the pin that read the pair's validation *through the window* against the menu
/// rows'. The claim it was making — one answer on both surfaces — is unchanged and now stronger:
/// they are not two validations that agree, they are one object both of them read
/// (`bothSurfacesReadOneManager`).
@Test("The toolbar pair targets the session's stack — and keeps its static labels")
func theToolbarPairCarriesAnExplicitTarget() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider)
let controller = boardToolbar(store: store, undo: manager)
let undoItem = try toolbarItem(controller, .boardUndo)
let redoItem = try toolbarItem(controller, .boardRedo)
#expect(undoItem.target === controller, "the app's own target, not the responder chain's lookup")
#expect(redoItem.target === controller)
undoItem.validate()
redoItem.validate()
#expect(undoItem.isEnabled == false, "an empty stack dims it")
#expect(redoItem.isEnabled == false)
provider.canUndo = true
provider.undoActionName = "Move 3 Cards"
provider.canRedo = true
provider.redoActionName = "Rename Lane"
undoItem.validate()
redoItem.validate()
#expect(undoItem.isEnabled)
#expect(redoItem.isEnabled)
// 03's one exception to the label rule, proven rather than asserted: the phrase the *menu*
// composes ("Undo Move 3 Cards") never reaches a toolbar label, whatever validation does.
#expect(manager.undoMenuItemTitle == "Undo Move 3 Cards")
#expect(undoItem.label == "Undo")
#expect(redoItem.label == "Redo")
#expect(undoItem.paletteLabel == "Undo", "the customize palette shows the static label too")
}
@Test("A toolbar item's own validation lands on the board's answer, lock included")
func theToolbarItemValidatesThroughTheManager() throws {
final class Lock { var isOn = false }
let lock = Lock()
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
let controller = boardToolbar(store: store, undo: manager)
let item = try toolbarItem(controller, .boardUndo)
// The link a headless run no longer has to fake: validation used to resolve a nil target
// through the key window, which a test host does not have, so the window stood in as the
// answer. The item is handed its target at construction now, so this is the shipped path
// end to end from `validate()`.
#expect(item.autovalidates, "AppKit revalidates it on user events; the observation covers the rest")
item.validate()
#expect(item.isEnabled == false, "an empty stack dims it")
provider.canUndo = true
item.validate()
#expect(item.isEnabled)
lock.isOn = true
item.validate()
#expect(item.isEnabled == false, "the lock disables the toolbar pair with the menu rows")
lock.isOn = false
item.validate()
#expect(item.isEnabled)
#expect(item.label == "Undo", "no crossing of validation ever moves the label")
}
@Test("Clicking the item crosses the board's stack, through the target it was given")
func theToolbarItemCrossesTheStack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let provider = FakeHistoryProvider()
provider.canUndo = true
provider.canRedo = true
let manager = BoardUndoManager(history: provider)
let controller = boardToolbar(store: store, undo: manager)
let undoItem = try toolbarItem(controller, .boardUndo)
let redoItem = try toolbarItem(controller, .boardRedo)
let target = try #require(undoItem.target as? NSObject)
let action = try #require(undoItem.action)
target.perform(action, with: undoItem)
#expect(provider.undoCount == 1, "the click reaches the session's stack, not a responder's")
let redoTarget = try #require(redoItem.target as? NSObject)
redoTarget.perform(try #require(redoItem.action), with: redoItem)
#expect(provider.redoCount == 1)
}
/// **One manager, two faces** — what "the toolbar mirrors the menu" means for this pair now that
/// neither of them goes through the responder chain (13-native-undo.md ▸ Rules ▸ the
/// command-surface bullet, re-ruled 2026-08-08; `BoardToolbar`'s header).
///
/// This is where the retired `theSpecsAbstainFromEnablement` pin went. Its claim — that the pair
/// must never grow a second answer able to disagree with the menu's — is the same claim, made
/// the only way it can be now that the items *do* carry a predicate: the predicate and the row
/// are reading one object, so they cannot come apart.
@Test("The toolbar pair and the Edit menu's rows read one and the same manager")
func bothSurfacesReadOneManager() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider)
let specs = BoardToolbar.specs(
store: store,
search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
appearance: AppearanceStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!, apply: { _ in }),
session: DragSession(),
undo: manager
)
// The row's side of it, derived exactly as `UndoMenuRow` derives it: the focused stack, with
// nothing text-shaped holding the keyboard.
func rowIsEnabled() -> Bool {
UndoCommandRouting.canUndo(
UndoCommandRouting.routedManager(stack: manager, firstResponder: nil)
)
}
for state in [false, true, false] {
provider.canUndo = state
let spec = try #require(specs.first { $0.identifier == .boardUndo })
#expect(spec.isEnabled == state)
#expect(spec.isEnabled == rowIsEnabled(), "one object answers both surfaces")
}
}
}