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
930 lines
37 KiB
Swift
930 lines
37 KiB
Swift
import AppKit
|
||
import Foundation
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// The provider seam and the board stack behind it (12-editions.md ▸ The provider seam;
|
||
/// 13-native-undo.md).
|
||
///
|
||
/// The steps here are **synthetic** on purpose: what this milestone builds is the stack and the
|
||
/// seam, and the real inverses arrive at the Writer boundary in the next one. A step that records
|
||
/// its own crossing is therefore the exact fixture — it proves the stack's grammar (one gesture one
|
||
/// step, undo flips to redo, a stale step falls through) without needing a board to move cards
|
||
/// around on.
|
||
|
||
// MARK: - Synthetic steps
|
||
|
||
/// Records every crossing, in order, and answers with whatever outcome the test asked for.
|
||
@MainActor
|
||
private final class StepLog {
|
||
|
||
private(set) var crossings: [String] = []
|
||
|
||
/// The direction each crossing was told it was — the argument the skip banner's verb comes from.
|
||
private(set) var directions: [HistoryDirection] = []
|
||
|
||
/// A step that applies in both directions — the ordinary case.
|
||
func step(_ name: String) -> HistoryStep {
|
||
step(name, undo: .applied, redo: .applied)
|
||
}
|
||
|
||
/// A step whose inverse declines as stale — 13's skip, without needing a foreign writer.
|
||
func staleStep(_ name: String) -> HistoryStep {
|
||
step(name, undo: .skipped, redo: .applied)
|
||
}
|
||
|
||
/// A step whose inverse could not be written — the disk-error fate, which is not staleness.
|
||
func failingStep(_ name: String) -> HistoryStep {
|
||
step(name, undo: .failed, redo: .applied)
|
||
}
|
||
|
||
func step(_ name: String, undo: HistoryStepOutcome, redo: HistoryStepOutcome) -> HistoryStep {
|
||
HistoryStep(
|
||
name: name,
|
||
undo: { [weak self] direction in
|
||
self?.crossings.append("undo \(name)")
|
||
self?.directions.append(direction)
|
||
return undo
|
||
},
|
||
redo: { [weak self] direction in
|
||
self?.crossings.append("redo \(name)")
|
||
self?.directions.append(direction)
|
||
return redo
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - The native stack
|
||
|
||
@MainActor
|
||
@Suite("History ▸ the native stack")
|
||
struct NativeHistoryProviderTests {
|
||
|
||
@Test("A fresh provider has nothing to cross and nothing to say about it")
|
||
func emptyStack() {
|
||
let provider = NativeHistoryProvider()
|
||
#expect(provider.canUndo == false)
|
||
#expect(provider.canRedo == false)
|
||
#expect(provider.undoActionName == nil)
|
||
#expect(provider.redoActionName == nil)
|
||
}
|
||
|
||
@Test("Registering a step arms Undo and names it — and runs nothing")
|
||
func registerArmsUndo() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
|
||
provider.register(log.step("Move Card"))
|
||
|
||
#expect(provider.canUndo)
|
||
#expect(provider.canRedo == false)
|
||
#expect(provider.undoActionName == "Move Card")
|
||
#expect(provider.redoActionName == nil)
|
||
#expect(log.crossings.isEmpty, "registration is not application")
|
||
}
|
||
|
||
@Test("Undo runs the inverse once and flips the step onto Redo, keeping its name")
|
||
func undoRunsTheInverseAndFlipsToRedo() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.step("Move 3 Cards"))
|
||
|
||
provider.undo()
|
||
|
||
#expect(log.crossings == ["undo Move 3 Cards"])
|
||
#expect(provider.canUndo == false)
|
||
#expect(provider.canRedo)
|
||
// The phrase names the gesture, not the direction — "Undo Move 3 Cards" becomes
|
||
// "Redo Move 3 Cards".
|
||
#expect(provider.redoActionName == "Move 3 Cards")
|
||
#expect(provider.undoActionName == nil)
|
||
}
|
||
|
||
@Test("Redo replays the write and arms Undo again — the classic dance, both ways")
|
||
func redoReplaysAndFlipsBack() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.step("Rename Lane"))
|
||
|
||
provider.undo()
|
||
provider.redo()
|
||
|
||
#expect(log.crossings == ["undo Rename Lane", "redo Rename Lane"])
|
||
#expect(provider.canUndo)
|
||
#expect(provider.canRedo == false)
|
||
#expect(provider.undoActionName == "Rename Lane")
|
||
|
||
provider.undo()
|
||
#expect(log.crossings == ["undo Rename Lane", "redo Rename Lane", "undo Rename Lane"])
|
||
}
|
||
|
||
@Test("One register call is one step — two registrations are two crossings, newest first")
|
||
func oneRegistrationIsOneStep() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
// Back to back, in one turn of the run loop: `groupsByEvent` must not fold these into one.
|
||
provider.register(log.step("Move Card"))
|
||
provider.register(log.step("Rename Card"))
|
||
|
||
#expect(provider.undoActionName == "Rename Card")
|
||
provider.undo()
|
||
#expect(log.crossings == ["undo Rename Card"])
|
||
#expect(provider.undoActionName == "Move Card")
|
||
|
||
provider.undo()
|
||
#expect(log.crossings == ["undo Rename Card", "undo Move Card"])
|
||
#expect(provider.canUndo == false)
|
||
}
|
||
|
||
@Test("A new step clears the redo stack — classic behaviour")
|
||
func registeringClearsRedo() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.step("Move Card"))
|
||
provider.undo()
|
||
#expect(provider.canRedo)
|
||
|
||
provider.register(log.step("Delete Card"))
|
||
|
||
#expect(provider.canRedo == false)
|
||
#expect(provider.redoActionName == nil)
|
||
#expect(provider.undoActionName == "Delete Card")
|
||
}
|
||
|
||
@Test("Undo on an empty stack does nothing at all")
|
||
func undoOnAnEmptyStackIsInert() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
|
||
provider.undo()
|
||
provider.redo()
|
||
|
||
#expect(log.crossings.isEmpty)
|
||
#expect(provider.canUndo == false)
|
||
#expect(provider.canRedo == false)
|
||
}
|
||
|
||
@Test("A stale step is skipped, not applied — and ⌘Z falls through to the next one")
|
||
func aStaleStepIsSkippedAndFallsThrough() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.step("Move Card"))
|
||
provider.register(log.staleStep("Rename Card"))
|
||
|
||
provider.undo()
|
||
|
||
// Both were reached in one ⌘Z: the stale one declined and was dropped, the next one applied.
|
||
#expect(log.crossings == ["undo Rename Card", "undo Move Card"])
|
||
#expect(provider.canUndo == false)
|
||
// Only the step that actually ran is redoable — a skipped step leaves nothing behind.
|
||
#expect(provider.canRedo)
|
||
#expect(provider.redoActionName == "Move Card")
|
||
}
|
||
|
||
@Test("A step whose write failed stays on the stack, and the crossing stops there")
|
||
func aFailedStepStaysAndStopsTheCrossing() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.step("Move Card"))
|
||
provider.register(log.failingStep("Rename Card"))
|
||
|
||
provider.undo()
|
||
|
||
// It was reached and it declined — and unlike a stale step it is still there to retry, with
|
||
// the step below it untouched underneath.
|
||
#expect(log.crossings == ["undo Rename Card"], "no fall-through: a refused disk is not a reason to try more")
|
||
#expect(provider.canUndo)
|
||
#expect(provider.undoActionName == "Rename Card")
|
||
#expect(provider.canRedo == false, "nothing landed, so nothing is redoable")
|
||
|
||
provider.undo()
|
||
#expect(log.crossings == ["undo Rename Card", "undo Rename Card"], "⌘Z can retry it")
|
||
}
|
||
|
||
@Test("A failed redo leaves the redo stack alone too")
|
||
func aFailedRedoStays() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.step("Move Card", undo: .applied, redo: .failed))
|
||
|
||
provider.undo()
|
||
provider.redo()
|
||
|
||
#expect(provider.canRedo, "still there to retry")
|
||
#expect(provider.redoActionName == "Move Card")
|
||
#expect(provider.canUndo == false)
|
||
}
|
||
|
||
@Test("A step is told which command it is being crossed by, not which half is running")
|
||
func stepsAreToldTheDirection() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.step("Rename Card"))
|
||
|
||
provider.undo()
|
||
provider.redo()
|
||
provider.undo()
|
||
|
||
// The third crossing runs the *undo* half again, and the second runs the half registered as
|
||
// `redo` — what each is told is ⌘Z, ⇧⌘Z, ⌘Z, which is what the skip banner has to say.
|
||
#expect(log.directions == [.undo, .redo, .undo])
|
||
}
|
||
|
||
@Test("A stack of nothing but stale steps empties itself and stops")
|
||
func anEntirelyStaleStackEmptiesItself() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.staleStep("Move Card"))
|
||
provider.register(log.staleStep("Rename Card"))
|
||
|
||
provider.undo()
|
||
|
||
#expect(log.crossings == ["undo Rename Card", "undo Move Card"])
|
||
#expect(provider.canUndo == false)
|
||
#expect(provider.canRedo == false)
|
||
}
|
||
|
||
@Test("Clearing drops both directions — the session-only rule's one call")
|
||
func clearEmptiesBothStacks() {
|
||
let log = StepLog()
|
||
let provider = NativeHistoryProvider()
|
||
provider.register(log.step("Move Card"))
|
||
provider.undo()
|
||
provider.register(log.step("Delete Card"))
|
||
|
||
provider.clear()
|
||
|
||
#expect(provider.canUndo == false)
|
||
#expect(provider.canRedo == false)
|
||
#expect(provider.undoActionName == nil)
|
||
#expect(provider.redoActionName == nil)
|
||
provider.undo()
|
||
#expect(log.crossings == ["undo Move Card"], "nothing crossed after the clear")
|
||
}
|
||
|
||
@Test("Two providers are two stacks — undo is board-local by construction")
|
||
func providersAreIndependent() {
|
||
let log = StepLog()
|
||
let one = NativeHistoryProvider()
|
||
let other = NativeHistoryProvider()
|
||
|
||
one.register(log.step("Move Card"))
|
||
|
||
#expect(one.canUndo)
|
||
#expect(other.canUndo == false)
|
||
|
||
other.undo()
|
||
#expect(log.crossings.isEmpty, "the other board's ⌘Z crosses nothing of this board's")
|
||
#expect(one.canUndo)
|
||
}
|
||
}
|
||
|
||
// MARK: - The AppKit adapter
|
||
|
||
/// A provider with no `NSUndoManager` anywhere in it — which is the point: `BoardUndoManager` is
|
||
/// tested against *this* rather than against the native stack, because what has to be true is that
|
||
/// the adapter works for any implementation of the seam (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)
|
||
}
|
||
}
|
||
}
|