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
232 lines
11 KiB
Swift
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")
|
|
}
|
|
}
|