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
This commit is contained in:
2026-08-08 18:55:34 -04:00
parent 78c32776d4
commit 1fd19dfb12
13 changed files with 926 additions and 201 deletions
+146 -94
View File
@@ -600,28 +600,32 @@ struct BoardSessionHistoryTests {
// MARK: - The command surface
/// **What the Edit menu's Undo/Redo rows and the toolbar's pair actually do** driven through the
/// platform machinery they ride on rather than described (11-command-nexus.md Menu commands, the
/// M row; 03-board-ui.md Toolbar; 13-native-undo.md Rules).
/// **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).
///
/// ### The app writes none of this, which is exactly why it is tested
/// ### What this suite covers since the surface became the app's own
///
/// There is no custom Undo/Redo menu code anywhere: the rows are the system's own nil-target
/// `undo:`/`redo:`, and the toolbar's two items carry the same selectors with the same nil target
/// (`BoardToolbar`). Every claim the design makes about them they enable on a stack with steps,
/// they dim under the read-only lock, the *menu* rows retitle themselves to "Undo Move 3 Cards"
/// while the *toolbar* labels stay static is therefore a claim about `NSWindow`'s own validation
/// reading the manager this app's window delegate hands back. Nothing here would fail loudly if the
/// wiring came undone; it would just quietly stop working, which is what these tests are for.
/// **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 a headless run can and cannot reach
/// What is pinned below is therefore what remains true and load-bearing:
///
/// `NSWindow.validateMenuItem(_:)` and `NSWindow.validateUserInterfaceItem(_:)` are the two methods
/// AppKit calls once a nil-target lookup has resolved to the window, and both answer fully in a test
/// process which is the half this app owns and the half that can break. The lookup *itself*
/// (`NSApp.target(forAction:to:from:)`) needs a **key window**, and a unit-test host has none, so
/// "the board window is what the chain resolves to when it is key" is the one link these tests
/// cannot close; it is standard responder-chain behaviour with no code of this app's in it.
/// - **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 {
@@ -641,13 +645,17 @@ struct UndoCommandSurfaceTests {
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: The menu rows
// MARK: What the delegate hands back
@Test("The Edit menu's rows read the board's stack, and retitle themselves from its step names")
@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)
@@ -667,9 +675,10 @@ struct UndoCommandSurfaceTests {
provider.canUndo = true
provider.undoActionName = "Move 3 Cards"
// 13's "the 06 vocabulary supplies menu titles ('Undo Move 3 Cards'), via NSUndoManager's
// dynamic retitling": the app never writes that string the platform composes it from the
// bare phrase the seam vends, and validation is when it lands on the row.
// 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)
@@ -752,8 +761,10 @@ struct UndoCommandSurfaceTests {
#expect(boardWindow.validateMenuItem(boardRow))
#expect(boardRow.title == "Undo Move 3 Cards")
// **No fall-through** (06-history-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.
// **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")
@@ -765,92 +776,93 @@ struct UndoCommandSurfaceTests {
// MARK: The toolbar twins
@Test("The toolbar pair validates identically to the menu rows — and keeps its static labels")
func theToolbarPairMatchesTheMenuRows() throws {
/// 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 controller = BoardToolbar.controller(
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()
)
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider)
let (window, hosted) = hostedWindow(manager)
defer { hosted.detach() }
let controller = boardToolbar(store: store, undo: manager)
/// The real items, built by the real delegate nil target, `undo:`/`redo:` actions.
func item(_ identifier: NSToolbarItem.Identifier) throws -> NSToolbarItem {
try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: identifier,
willBeInsertedIntoToolbar: true
))
}
let undoItem = try item(.boardUndo)
let redoItem = try item(.boardRedo)
#expect(undoItem.target == nil, "nil target: the chain resolves it, exactly as the menu row's is")
#expect(redoItem.target == nil)
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)
// `validateUserInterfaceItem` is what `NSToolbarItem.validate()` asks its resolved target,
// and `validateMenuItem` is what a menu row's asks one predicate, two doors.
#expect(window.validateUserInterfaceItem(undoItem) == false)
#expect(window.validateUserInterfaceItem(redoItem) == false)
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"
let undoRow = menuItem("undo:")
let redoRow = menuItem("redo:")
#expect(window.validateUserInterfaceItem(undoItem) == window.validateMenuItem(undoRow))
#expect(window.validateUserInterfaceItem(redoItem) == window.validateMenuItem(redoRow))
#expect(window.validateUserInterfaceItem(undoItem))
#expect(window.validateUserInterfaceItem(redoItem))
undoItem.validate()
redoItem.validate()
#expect(undoItem.isEnabled)
#expect(redoItem.isEnabled)
// 03's one exception to the label rule, proven rather than asserted: validation rewrote the
// *menu* row's title and left the toolbar item's label exactly where it was.
#expect(undoRow.title == "Undo Move 3 Cards")
// 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 theToolbarItemValidatesThroughTheWindow() throws {
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 zoomDomain = "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)"
defer { UserDefaults.standard.removePersistentDomain(forName: zoomDomain) }
let toolbar = BoardToolbar.controller(
store: store,
search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: zoomDomain)!),
appearance: AppearanceStore(defaults: UserDefaults(suiteName: zoomDomain + ".appearance")!, apply: { _ in }),
session: DragSession()
)
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
let (window, hosted) = hostedWindow(manager)
defer { hosted.detach() }
let controller = boardToolbar(store: store, undo: manager)
let item = try #require(toolbar.toolbar(
toolbar.toolbar,
itemForItemIdentifier: .boardUndo,
willBeInsertedIntoToolbar: true
))
// The one link a headless run cannot make: `NSToolbarItem.validate()` resolves its target
// through the key window, and a test host has none. Standing the window in as the target is
// that lookup's *answer* which is what nil-target means when a board window is key so
// what this asserts is the item's own validation path, end to end from `validate()`.
item.target = window
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()
@@ -870,27 +882,67 @@ struct UndoCommandSurfaceTests {
#expect(item.label == "Undo", "no crossing of validation ever moves the label")
}
@Test("The pair's enablement is deliberately not a predicate of the toolbar's own")
func theSpecsAbstainFromEnablement() throws {
@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()
session: DragSession(),
undo: manager
)
// Every other item mirrors its menu row's predicate; these two mirror the *mechanism*. A
// spec-level `isEnabled` here would be a second answer able to disagree with the responder
// chain's and it would have to read a stack the toolbar has no route to, since the board's
// window is what owns that answer.
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
let spec = try #require(specs.first { $0.identifier == identifier })
#expect(spec.isEnabled, "abstention, not enablement: AppKit's own validation decides")
#expect(spec.isOn == nil)
// 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")
}
}
}
+126 -15
View File
@@ -50,22 +50,46 @@ private func makeAppearance() -> AppearanceStore {
return AppearanceStore(defaults: UserDefaults(suiteName: name)!, apply: { _ in })
}
/// The board catalog, with the three collaborators every test here supplies the same way: a fresh
/// zoom store, a fresh appearance store, and a drag session with nothing in flight.
/// The board catalog, with the four collaborators every test here supplies the same way: a fresh
/// zoom store, a fresh appearance store, a drag session with nothing in flight, and no undo stack
/// which is the honest default for a suite whose subject is the *catalog*, and the Undo/Redo pair's
/// own tests pass one (`undoPairCrossesTheSessionsStack`).
@MainActor
private func boardSpecs(
store: BoardStore,
search: BoardSearchPresentation = BoardSearchPresentation(),
zoom: BoardZoomStore? = nil,
appearance: AppearanceStore? = nil,
session: DragSession = DragSession()
session: DragSession = DragSession(),
undo: BoardUndoManager? = nil
) -> [ToolbarItemSpec] {
BoardToolbar.specs(
store: store,
search: search,
zoom: zoom ?? makeZoom(),
appearance: appearance ?? makeAppearance(),
session: session
session: session,
undo: undo
)
}
/// A board stack with a real substrate behind it the shape `BoardWindowHost` hands the toolbar
/// (`AppModel`'s session composes exactly this pair).
@MainActor
private func makeUndo(isReadOnly: @escaping @MainActor () -> Bool = { false }) -> (BoardUndoManager, NativeHistoryProvider) {
let provider = NativeHistoryProvider()
return (BoardUndoManager(history: provider, isReadOnly: isReadOnly), provider)
}
/// One step that records its crossings `HistoryProviderTests`' synthetic fixture, in the one shape
/// this suite needs: what the toolbar pair must prove is that firing it *reaches* the stack, not what
/// the stack then does to disk.
@MainActor
private func countingStep(_ name: String, crossings: @escaping @MainActor () -> Void) -> HistoryStep {
HistoryStep(
name: name,
undo: { _ in crossings(); return .applied },
redo: { _ in crossings(); return .applied }
)
}
@@ -240,17 +264,104 @@ struct BoardToolbarTests {
#expect(specs.map(\.label) == [
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Appearance", "Search",
])
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card'), which a toolbar
// label doesn't track". They are also the two items with no action of their own: nil target,
// responder-chain selectors, "matching their menu items" by using the same lookup.
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels the menu
// titles are rewritten dynamically ('Undo Move Card'), which a toolbar label doesn't
// track". They are ordinary buttons since the command surface became the app's own
// (13-native-undo.md Rules the command-surface bullet, re-ruled 2026-08-08) the label
// exception survived the mechanism that motivated it, because it was never about *how* the
// item fires.
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
let spec = try #require(specs.spec(identifier))
guard case let .responderAction(selector) = spec.behavior else {
Issue.record("\(identifier.rawValue) must reach the responder chain like its menu row")
guard case .button = spec.behavior else {
Issue.record("\(identifier.rawValue) must carry an explicit target over the session's stack")
return
}
#expect(selector == NSSelectorFromString(spec.label.lowercased() + ":"))
#expect(spec.isOn == nil, "\(spec.label) is a push button, not a toggle")
}
}
/// **The pair's predicate is the menu rows' own object** (13-native-undo.md Rules the
/// command-surface bullet, re-ruled 2026-08-08): both surfaces read one `BoardUndoManager`, so
/// enablement here is `canUndo`/`canRedo` and nothing of the toolbar's own.
///
/// This replaces the nil-target pin the pair carried until that re-ruling the responder-chain
/// route it named is unreachable on a SwiftUI window, and what took its place is a predicate this
/// suite can read directly rather than one only `NSWindow` could answer.
@Test("Undo and Redo mirror the session's stack, and firing them crosses it")
func undoPairCrossesTheSessionsStack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (manager, provider) = makeUndo()
let specs = boardSpecs(store: store, undo: manager)
let undo = try #require(specs.spec(.boardUndo))
let redo = try #require(specs.spec(.boardRedo))
#expect(!undo.isEnabled, "an empty stack dims it")
#expect(!redo.isEnabled)
var crossings: [String] = []
provider.register(countingStep("Move 3 Cards") { crossings.append("undo") })
#expect(undo.isEnabled, "a step on the stack lights it")
#expect(!redo.isEnabled, "and nothing has been crossed yet")
undo.activate()
#expect(crossings == ["undo"], "firing the item crosses the board's own stack")
#expect(!undo.isEnabled, "the stack is empty again")
#expect(redo.isEnabled, "and the crossed step is on the other one")
redo.activate()
#expect(crossings == ["undo", "undo"], "the synthetic step records both halves the same way")
#expect(undo.isEnabled)
}
/// The read-only lock disables the pair with every other mutating command, and it does it in the
/// one place it is decided `BoardUndoManager.isReadOnly` (13-native-undo.md Rules locks;
/// 02-architecture.md § "The lock's scope"). The stack is untouched, which is why the items come
/// back when it clears.
@Test("The read-only lock dims the pair, steps and all")
func theLockDimsThePair() 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 (manager, provider) = makeUndo(isReadOnly: { lock.isOn })
provider.register(countingStep("Move Card") {})
provider.register(countingStep("Rename Lane") {})
provider.undo()
let specs = boardSpecs(store: store, undo: manager)
let undo = try #require(specs.spec(.boardUndo))
let redo = try #require(specs.spec(.boardRedo))
#expect(undo.isEnabled)
#expect(redo.isEnabled)
lock.isOn = true
#expect(!undo.isEnabled, "disabled with every other mutating command")
#expect(!redo.isEnabled)
#expect(provider.canUndo, "an enablement answer, not a clearing — the stack survives")
lock.isOn = false
#expect(undo.isEnabled, "and resumes when the lock clears")
#expect(redo.isEnabled)
}
/// A window whose session has gone hands the catalog `nil`, and the pair reads that as an empty
/// stack rather than as an error the same quiet the adapter gives a board with no provider
/// (`BoardUndoManager.history`).
@Test("With no stack in front the pair is dim, and firing it does nothing")
func theUndoPairAbstainsWithNoStack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = boardSpecs(store: store, undo: nil)
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
let spec = try #require(specs.spec(identifier))
#expect(!spec.isEnabled)
spec.activate()
}
}
@@ -292,7 +403,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
// 03's three customization sentences: "right-click Customize Toolbar, drag to rearrange,
// system overflow and icon/text display options".
@@ -321,7 +432,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
#expect(search.focusField == nil, "nothing to focus until the item exists")
@@ -356,7 +467,7 @@ struct BoardToolbarTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
let item = try #require(controller.toolbar(
controller.toolbar,
@@ -390,7 +501,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
_ = controller.toolbar(
controller.toolbar,
+169
View File
@@ -0,0 +1,169 @@
import AppKit
import Testing
@testable import Kanban
// MARK: - Fixtures
/// A board stack with a real substrate behind it the pair a session composes (`AppModel`).
@MainActor
private func makeStack(isReadOnly: @escaping @MainActor () -> Bool = { false }) -> (BoardUndoManager, NativeHistoryProvider) {
let provider = NativeHistoryProvider()
return (BoardUndoManager(history: provider, isReadOnly: isReadOnly), provider)
}
/// A step that applies in both directions and does nothing else this suite's subject is which
/// manager a row *reaches*, never what crossing it does to disk.
@MainActor
private func step(_ name: String) -> HistoryStep {
HistoryStep(name: name, undo: { _ in .applied }, redo: { _ in .applied })
}
/// A text view's delegate vending a manager of its own `CardBodySurface` and `CardRawSourceView`
/// in one line, which is the shape 13-native-undo.md Undo routing calls "an editor's
/// delegate-vended manager".
@MainActor
private final class UndoVendingDelegate: NSObject, NSTextViewDelegate {
let manager = UndoManager()
func undoManager(for view: NSTextView) -> UndoManager? { manager }
}
// MARK: - Routing
/// **Which manager the app's own Undo/Redo rows answer with** (13-native-undo.md Undo routing, the
/// predicate; Rules the command-surface bullet, re-ruled 2026-08-08 the rule unchanged, its
/// enactment moved from the window delegate to the command layer).
///
/// The decision is three lines that are invisible until they are wrong, which is `BoardUndoRouting`'s
/// own reason for being a pure enum and this suite's for pinning every branch of the one above it.
@MainActor
@Suite("Undo commands ▸ routing")
struct UndoCommandRoutingTests {
@Test("With nothing holding the keyboard, a row answers with the focused stack")
func noResponderAnswersTheStack() {
let (manager, _) = makeStack()
#expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: nil) === manager)
#expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: nil) == nil)
}
@Test("A responder that is not a text surface is not one — the stack answers")
func nonTextResponderAnswersTheStack() {
let (manager, _) = makeStack()
#expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSResponder()) === manager)
#expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSView()) === manager)
// A field that has focus without editing is the board's, not the field editor's: AppKit
// installs the field editor only when editing begins (`BoardUndoRouting.isTextEditing`).
#expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSTextField()) === manager)
#expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: NSView()) == nil)
}
@Test("An editor's own manager wins the keyboard, whatever the board stack holds")
func aFocusedEditorAnswersWithItsOwnManager() {
let (manager, provider) = makeStack()
provider.register(step("Move 3 Cards"))
let delegate = UndoVendingDelegate()
let editor = NSTextView()
editor.allowsUndo = true
editor.delegate = delegate
let routed = UndoCommandRouting.routedManager(stack: manager, firstResponder: editor)
#expect(routed === delegate.manager)
// **No fall-through** (13 Undo routing): the editor's stack is empty and the board's is
// not, and the row still answers with the editor's "exhausting a focused editor's stack
// beeps; it never reaches board history". The row that renders over this is disabled, and
// the Z that fires against it beeps.
#expect(manager.canUndo, "the board has a step to cross, and the row will not cross it")
#expect(UndoCommandRouting.canUndo(routed) == false)
#expect(UndoCommandRouting.undoTitle(of: routed) == "Undo", "the editor's bare verb, not the board's phrase")
}
@Test("A text surface with no manager of its own answers nothing at all")
func aTextSurfaceWithNoManagerAnswersNil() {
let (manager, provider) = makeStack()
provider.register(step("Move 3 Cards"))
let editor = NSTextView()
editor.allowsUndo = true
// No delegate to vend one and no window to inherit one from: the responder chain ends here.
// `nil` rather than the board's stack, which is the same no-fall-through rule read from its
// other end a text surface holding the keyboard is never a reason to reach past it.
#expect(editor.undoManager == nil)
#expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: editor) == nil)
#expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: editor) == nil)
}
}
// MARK: - Titles and enablement
/// **What a rendered row says and whether it is live** the two halves the rows route along with the
/// action, because a row that advertised a step it would not cross, or swallowed Z while dimmed off
/// the wrong stack, is exactly how no-fall-through fails from the user's side (13-native-undo.md
/// Rules the command-surface bullet, re-ruled 2026-08-08).
@MainActor
@Suite("Undo commands ▸ titles and enablement")
struct UndoCommandTitleTests {
@Test("No manager is the bare verb, disabled — a row with nothing in front of it")
func noManagerIsTheBareVerb() {
#expect(UndoCommandRouting.undoTitle(of: nil) == "Undo")
#expect(UndoCommandRouting.redoTitle(of: nil) == "Redo")
#expect(UndoCommandRouting.canUndo(nil) == false)
#expect(UndoCommandRouting.canRedo(nil) == false)
}
@Test("A named step composes the platform's title, and lights the row")
func aNamedStepComposesTheTitle() {
let (manager, provider) = makeStack()
#expect(UndoCommandRouting.undoTitle(of: manager) == "Undo", "an empty stack keeps the bare verb")
#expect(UndoCommandRouting.canUndo(manager) == false)
provider.register(step("Move 3 Cards"))
#expect(UndoCommandRouting.canUndo(manager))
#expect(UndoCommandRouting.undoTitle(of: manager) == "Undo Move 3 Cards")
#expect(UndoCommandRouting.canRedo(manager) == false, "nothing crossed yet")
provider.undo()
#expect(UndoCommandRouting.canUndo(manager) == false)
#expect(UndoCommandRouting.canRedo(manager))
#expect(UndoCommandRouting.redoTitle(of: manager) == "Redo Move 3 Cards")
}
/// The trim is why these helpers exist at all rather than the rows reading `undoMenuItemTitle`
/// directly: `BoardUndoManager` trims its own nameless composition, and a plain `NSUndoManager`
/// a text view's, a field editor's does not, leaving "Undo " with a trailing space on the row.
@Test("A nameless plain NSUndoManager still reads as the bare verb")
func aNamelessTextManagerTrimsToTheBareVerb() {
let text = UndoManager()
#expect(UndoCommandRouting.undoTitle(of: text) == "Undo")
#expect(UndoCommandRouting.redoTitle(of: text) == "Redo")
}
/// The read-only lock reaches the rows through the very object they read one answer, wherever
/// it is asked from (13 Rules locks; `BoardUndoManager.canUndo`).
@Test("The read-only lock dims the row and leaves its name standing")
func theLockDimsTheRow() {
final class Lock { var isOn = false }
let lock = Lock()
let (manager, provider) = makeStack(isReadOnly: { lock.isOn })
provider.register(step("Move Card"))
#expect(UndoCommandRouting.canUndo(manager))
lock.isOn = true
#expect(UndoCommandRouting.canUndo(manager) == false)
#expect(UndoCommandRouting.undoTitle(of: manager) == "Undo Move Card", "the stack survives the lock")
lock.isOn = false
#expect(UndoCommandRouting.canUndo(manager), "and resumes when it clears")
}
}