The provider seam 12 promised: HistoryProviding speaks 13's vocabulary — register a HistoryStep (bare 06 phrase plus undo/redo closures returning applied or skipped), canUndo/canRedo, action names, clear — and no UndoManager type appears anywhere in it, proven by a fake that satisfies the seam with counters. The base provider wraps a private UndoManager with groupsByEvent off so coalescing stays the Writer call site's decision; undo re-registers the reversed step from inside the undo, which makes a stale-skipped step vanish for free and the crossing loop fall through to the next. BoardUndoManager adapts the protocol to the responder chain — a stackless UndoManager subclass answering from the provider — so Pro's git provider inherits menu enablement, dynamic titles, and the nil-target toolbar pair by binding the protocol. One stack per board session, born in beginSession, cleared in the close flush; every window over the board answers it through windowWillReturnUndoManager. Headless probes shaped the routing: a real NSTextView's own manager wins natively, but a field editor's does not — BoardUndoRouting answers the per-window text manager while any NSText is first responder, so a search-field typo never crosses a board step. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
497 lines
18 KiB
Swift
497 lines
18 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] = []
|
|
|
|
/// 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 — 13's staleness skip, without needing a foreign writer.
|
|
func staleStep(_ name: String) -> HistoryStep {
|
|
step(name, undo: .skipped, redo: .applied)
|
|
}
|
|
|
|
func step(_ name: String, undo: HistoryStepOutcome, redo: HistoryStepOutcome) -> HistoryStep {
|
|
HistoryStep(
|
|
name: name,
|
|
undo: { [weak self] in
|
|
self?.crossings.append("undo \(name)")
|
|
return undo
|
|
},
|
|
redo: { [weak self] in
|
|
self?.crossings.append("redo \(name)")
|
|
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 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("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"))
|
|
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 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")
|
|
}
|
|
}
|