Files
lanework/KanbanTests/HistoryProviderTests.swift
T
rzen 3b19883593 Lanework Pro is a subscription — the tier seam, StoreKit 2, and Settings
Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution,
ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro —
deliberately no .lapsed case; unsubscribed and lapsed are one state) and
the pure decision Tier.resolve(from:now:) over SubscriptionFacts
(expiration + willAutoRenew), unit-tested through all five named states:
free, active, lapsed, offline-grace, never-online.

The facts are a persisted cache (standard defaults), not a live view:
StoreKit ages an expired subscription out of currentEntitlements locally,
so an offline device and a real lapse are indistinguishable from that
property alone — the cache holds the last answer, empty entitlements
read as silence, and holds end only on a definitive answer (revocation,
or the subscription-group status read Settings performs). That is 12's
offline-grace trade, resolved toward the paying user.

ProEntitlement is the local adapter (currentEntitlements +
Transaction.updates, started from launch, never from a test host);
ProStorefront holds everything networked (product load, purchase,
AppStore.sync) and only the Settings section ever constructs one — the
split is the enforcement of "never network on the open path".
beginSession reads the tier once at composition; BoardSession.tier is a
let with no path back in, so a lapse never rebinds an open session.
makeHistoryProvider now takes the tier; both tiers bind the native stack
until pro-m1 builds the git provider — the seam's consumer is named, not
invented early.

Settings gains the Pro section (subscribe with localized price, manage,
restore; a quiet unreachable line, no indefinite spinner) — the third of
the exactly-three Pro mentions; the About line gains its "…in Settings"
pointer now that there is a Settings to point at. A successful purchase
or restore offers once to reopen open boards (close + reopen through the
ordinary paths). Configuration.storekit wired into the scheme's run
action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side
steps and the rule that the product must not be configured before then.

1901 tests in 319 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 18:14:58 -04:00

930 lines
37 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (Pro's git provider binds the same protocol
/// in pro-m1).
@MainActor
private final class FakeHistoryProvider: HistoryProviding {
var canUndo = false
var canRedo = false
var undoActionName: String?
var redoActionName: String?
private(set) var registered: [String] = []
private(set) var undoCount = 0
private(set) var redoCount = 0
private(set) var clearCount = 0
func register(_ step: HistoryStep) { registered.append(step.name) }
func undo() { undoCount += 1 }
func redo() { redoCount += 1 }
func clear() { clearCount += 1 }
}
@MainActor
@Suite("History ▸ the AppKit adapter")
struct BoardUndoManagerTests {
@Test("Enablement is the provider's answer, not a stack of the adapter's own")
func enablementMirrorsTheProvider() {
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider)
#expect(manager.canUndo == false)
#expect(manager.canRedo == false)
provider.canUndo = true
provider.canRedo = true
#expect(manager.canUndo)
#expect(manager.canRedo)
}
@Test("The menu titles are the platform's composition over the step's own phrase")
func menuTitlesComposeFromTheStepName() {
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider)
// Nothing to cross: the bare verb, with no trailing space where the name would go.
#expect(manager.undoMenuItemTitle == "Undo")
#expect(manager.redoMenuItemTitle == "Redo")
provider.canUndo = true
provider.undoActionName = "Move 3 Cards"
provider.canRedo = true
provider.redoActionName = "Rename Lane"
#expect(manager.undoActionName == "Move 3 Cards")
#expect(manager.undoMenuItemTitle == "Undo Move 3 Cards")
#expect(manager.redoMenuItemTitle == "Redo Rename Lane")
}
@Test("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)
#expect(secondSession.history.canUndo == false)
secondSession.undoManager.undo()
#expect(log.crossings.isEmpty)
#expect(firstSession.history.canUndo, "the other board's ⌘Z left this one's stack alone")
}
@Test("Closing a board empties its stack — session-only persistence")
func closingClearsTheStack() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
let history = session.history
let log = StepLog()
history.register(log.step("Move Card"))
#expect(history.canUndo)
await model.closeBoard(ref: ref, cause: .userClose)
#expect(model.session(for: ref) == nil)
#expect(history.canUndo == false, "reopening the board starts empty")
#expect(history.canRedo == false)
#expect(log.crossings.isEmpty)
}
@Test("The 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, "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")
}
@Test("The tier reaches the composition root, and the session records what it composed under")
func theTierIsAComposedFact() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
// 12-editions.md ▸ The entitlement: the tier is read at composition, once, and handed to the
// root that binds the provider. Both tiers bind the native stack until pro-m1 — what this
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body.
var seen: [Tier] = []
model.currentTier = { .pro }
model.makeHistoryProvider = { _, tier in
seen.append(tier)
return NativeHistoryProvider()
}
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(seen == [.pro])
#expect(session.tier == .pro, "the session carries the fact it composed under")
}
@Test("A tier change never reaches a session that is already open")
func aLapseNeverRebindsAnOpenSession() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
#expect(try #require(model.session(for: ref)).tier == .pro)
// The subscription lapses mid-session — the one thing 12 ▸ The entitlement says must not
// disturb a board that is already on screen: "an open board finishes with the provider it
// composed; the next open composes the native stack over inert `.git`".
model.currentTier = { .free }
#expect(try #require(model.session(for: ref)).tier == .pro)
}
@Test("The purchase flow's reopen ends every session, because that is what recomposing means")
func reopeningEndsTheSessions() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let ref = try openBoard(model, at: fixture.root)
#expect(model.hasOpenBoards)
// 12 ▸ The entitlement: "Subscribe takes effect at each board's next open ... The purchase
// flow offers to reopen open boards." There is no rebinding-in-place to test for, and that
// is the finding: reopening *is* ending the session and composing a new one, so what this
// pins is the ending. The reopen half needs SwiftUI's window actions, which this host has
// none of — the URLs simply buffer until an opener exists (`AppModel.openBoard`), which is
// the same path a cold-launch Finder open already takes.
await model.reopenOpenBoards()
#expect(model.session(for: ref) == nil)
#expect(model.hasOpenBoards == false)
#expect(model.storeRegistry.openBoardCount == 0, "the store and its watcher went with the session")
}
}
// 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).
///
/// ### The app writes none of this, which is exactly why it is tested
///
/// 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.
///
/// ### What a headless run can and cannot reach
///
/// `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.
@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.boardUndoManager = { manager }
controller.attach(to: window)
return (window, controller)
}
private func menuItem(_ selector: String) -> NSMenuItem {
NSMenuItem(title: selector == "undo:" ? "Undo" : "Redo", action: NSSelectorFromString(selector), keyEquivalent: "")
}
// MARK: The menu rows
@Test("The Edit menu's rows read the board's stack, and retitle themselves 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 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.
#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("Every window over one board answers with that board's one stack")
func cardWindowsShareTheBoardsStack() 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))
// The board window and one of its card windows, wired the way their hosts wire them.
let (boardWindow, boardController) = hostedWindow(session.undoManager)
defer { boardController.detach() }
let (cardWindow, cardController) = hostedWindow(session.undoManager)
defer { cardController.detach() }
let boardRow = menuItem("undo:")
let cardRow = menuItem("undo:")
session.history.register(StepLog().step("Move 3 Cards"))
#expect(boardWindow.validateMenuItem(boardRow))
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
#expect(boardRow.title == "Undo Move 3 Cards")
#expect(cardRow.title == boardRow.title)
}
// MARK: The toolbar twins
@Test("The toolbar pair validates identically to the menu rows — and keeps its static labels")
func theToolbarPairMatchesTheMenuRows() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider)
let (window, hosted) = hostedWindow(manager)
defer { hosted.detach() }
/// 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)
// `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)
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))
// 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")
#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 {
final class Lock { var isOn = false }
let lock = Lock()
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let toolbar = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
let (window, hosted) = hostedWindow(manager)
defer { hosted.detach() }
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
#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("The pair's enablement is deliberately not a predicate of the toolbar's own")
func theSpecsAbstainFromEnablement() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
// 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)
}
}
}