Files
lanework/KanbanTests/UndoCommandsTests.swift
T
rzen 57542177c1 The undo rows stop hearing their own echo — a text manager answers canRedo by posting the checkpoint that re-invalidated the row, forever
Diagnosed by sampling a live frozen instance: with a text surface focused,
RedoMenuRow's body reads the routed manager's title, NSUndoManager.canRedo
posts NSUndoManagerCheckpoint synchronously, UndoCommandTicker bumps its
observed revision mid-body, and SwiftUI schedules the re-evaluation whose
own read posts the next checkpoint — the main thread never returns to the
event loop (~99% CPU, app frozen). Board-routed reads never echo, because
BoardUndoManager's overrides answer from the provider without posting —
which is why the board-only live probe (21/21) never met the loop.

The rows now derive title and enablement inside
UndoCommandTicker.silencingReadEchoes, a synchronous main-actor window in
which bump() drops what arrives: a read cannot change the state it reads,
so the echo carries no information and dropping it loses nothing. Genuine
checkpoints — registration closing a group, a crossing — still land.

Three regression tests pin the mechanism, including the asymmetry that
made the redo side the fuel: canUndo answers silently, canRedo posts.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 22:50:00 -04:00

232 lines
11 KiB
Swift

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")
}
}
// MARK: - The ticker's silenced window
/// **The reads that post, kept out of the ticker they would otherwise re-enter** — the livelock
/// regression (diagnosed 2026-08-08 by sampling a frozen instance). A plain `NSUndoManager` fires
/// `NSUndoManagerCheckpoint` synchronously from `canRedo` — and from the composed redo title through
/// it (`canUndo` posts nothing, an asymmetry pinned below); the ticker subscribes to exactly that
/// notification; and a bump landing mid-`body` re-invalidates the row whose read posted it, forever
/// (`UndoCommandTicker.silencingReadEchoes`). `BoardUndoManager` never echoes — its overrides answer
/// from the provider — which is why every fixture here is the plain text-manager shape the rows
/// route to while an editor holds the keyboard.
@MainActor
@Suite("Undo commands ▸ ticker")
struct UndoCommandTickerTests {
@Test("A checkpoint reaches the ticker — the subscription the silence guards against is live")
func aGenuineCheckpointBumps() {
let before = UndoCommandTicker.shared.revision
// `canUndo` answers without posting — the asymmetry that made the redo side the loop's
// fuel in the wild, pinned so a platform change is noticed here first.
_ = UndoManager().canUndo
#expect(UndoCommandTicker.shared.revision == before)
// `canRedo` posts the checkpoint synchronously before answering — the very side effect the
// silenced window exists for, here arriving unsilenced.
_ = UndoManager().canRedo
#expect(UndoCommandTicker.shared.revision > before)
}
@Test("A row's own derivation leaves the ticker still — the livelock regression")
func silencedReadsDoNotBump() {
let text = UndoManager()
let before = UndoCommandTicker.shared.revision
let answers = UndoCommandTicker.shared.silencingReadEchoes {
(undo: UndoCommandRouting.undoTitle(of: text),
redo: UndoCommandRouting.redoTitle(of: text),
canUndo: UndoCommandRouting.canUndo(text),
canRedo: UndoCommandRouting.canRedo(text))
}
#expect(UndoCommandTicker.shared.revision == before, "the reads' own echoes never land")
#expect(answers == (undo: "Undo", redo: "Redo", canUndo: false, canRedo: false),
"and the answers themselves are untouched by the silence")
}
@Test("The silence is scoped to the read, and nests")
func silenceIsScopedAndNests() {
let before = UndoCommandTicker.shared.revision
UndoCommandTicker.shared.silencingReadEchoes {
UndoCommandTicker.shared.silencingReadEchoes { _ = UndoManager().canRedo }
// Still inside the outer window after the inner one closes.
_ = UndoManager().canRedo
}
#expect(UndoCommandTicker.shared.revision == before, "nothing inside the window lands")
_ = UndoManager().canRedo
#expect(UndoCommandTicker.shared.revision > before, "and the window closes behind the read")
}
}