Files
lanework/KanbanTests/ToolbarTests.swift
T
rzen fda19881de A stale window dismantle stops undoing a fresher attach — the card window keeps its toolbar across a raw-source toggle
Toggling a card window between Edit and Raw Source could leave it with no toolbar at
all, which collapses AppKit's two-line title-and-subtitle chrome down to the single
combined "⟨title⟩ — ⟨board⟩ › ⟨lane⟩" line (the malformed titlebar reported on the
Pipeline card) — that stacked rendering only appears when a toolbar is installed.

Root cause was in HostedWindowController.attach/detach (WindowAccessor.swift), shared
by every window this app hosts. WindowAccessor's own doc comment already recorded that
SwiftUI "dismantles and re-makes the background representable" on macOS 26, and every
slot attach()/detach() manage was made repeat-safe against that (BoardChromeTests
.theSlotReappliesToTheNextWindow pins it for extendsUnderTitlebar) — but that safety
net assumes a dismantle always arrives before its matching attach, and nothing
guarantees that ordering. A content swap deep in the card window's tree (the raw-source
outlet replacing the whole content area, or an edit-mode flush landing a reload) is the
kind of churn that can make SwiftUI recreate the representable mid-session. If the old
view's dismantleNSView lands after the new view's attach has already reinstalled the
toolbar, the old identity-blind detach() had no way to tell — its guards check "is my
state still installed", which is coincidentally true right after a fresh reattach too —
so it tore the toolbar, the titlebar accessory and the delegate proxy right back off a
window a newer attach had just finished configuring, with nothing left to reinstall it.

Fix: attach(to:through:)/detach(through:) track which WindowAccessor view is the
current owner (HostedWindowController.attachedThroughView) and refuse a detach for any
other view outright. WindowAccessor.makeNSView/dismantleNSView pass their own view
through; every existing bare attach(to:)/detach() caller (this file's own tests,
BoardChromeTests, InlineEditWriteTests, HistoryProviderTests) is untouched — the guard
only engages when both sides of a call name a view. State re-asserted at the ownership
point rather than a notification-race band-aid, the same shape a429a7e's
titlebar-transparency fix used.

Could not reproduce live — the screen is locked in this environment (CGSSessionScreen
IsLocked). Established the mechanism from code and verified it with a targeted harness
instead: ToolbarStaleDismantleTests (ToolbarTests.swift) drives HostedWindowController
directly through the exact race (attach view A, attach view B over the same still-live
window, then a stale detach for view A), confirms the toolbar and delegate survive, and
separately confirms the legitimate owner's detach, the ordinary detach-then-attach
order, and every viewless caller all behave exactly as before. Verified the new test
fails without the fix (temporarily disabled the identity guard, reran in isolation, saw
the expected failure) before restoring it.

Tests: 3223 KanbanTests, 3220 passing. The only 3 failures are PointerLatencyTests'
documented locked-screen environmental mode (CGEvent-driven clicks need a live screen)
— reran that suite alone and got the identical 3 failures, none of which touch this
window-attachment code.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 12:06:01 -04:00

1236 lines
57 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 Testing
@testable import Kanban
// MARK: - Fixtures
/// Two lanes, two cards in the first — enough board for New Card to have a target.
@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"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
return fixture
}
/// A board with no lanes — where "card creation has no target … New Card disables via menu
/// validation until a lane exists" (04-interactions.md ▸ The map).
@MainActor
private func makeEmptyBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
return fixture
}
private extension Array where Element == ToolbarItemSpec {
@MainActor
func spec(_ identifier: NSToolbarItem.Identifier) -> ToolbarItemSpec? {
first { $0.identifier == identifier }
}
}
/// A zoom store on a scratch defaults domain — the level is app-wide and persisted, so a suite that
/// used `.standard` would zoom the developer's own boards (`StyleRecents`' injection, for its reason).
@MainActor
private func makeZoom(level: CGFloat = BoardZoom.actualSize) -> BoardZoomStore {
let name = "dev.rzen.indie.Kanban.toolbar-tests.\(UUID().uuidString)"
let store = BoardZoomStore(defaults: UserDefaults(suiteName: name)!)
store.setLevel(level)
return store
}
/// An appearance store on a scratch defaults domain, apply seam stubbed out — this suite's subject is
/// the toolbar item, not the store itself (`AppearanceTests.swift` owns that), so nothing here should
/// touch `NSApp` (`makeZoom`'s reason, turned toward AppKit).
@MainActor
private func makeAppearance() -> AppearanceStore {
let name = "dev.rzen.indie.Kanban.toolbar-tests.appearance.\(UUID().uuidString)"
return AppearanceStore(defaults: UserDefaults(suiteName: name)!, apply: { _ in })
}
/// The board catalog, with the four collaborators every test here supplies the same way: a fresh
/// zoom store, a fresh appearance store, a drag session with nothing in flight, and no undo stack —
/// which is the honest default for a suite whose subject is the *catalog*, and the Undo/Redo pair's
/// own tests pass one (`undoPairCrossesTheSessionsStack`).
@MainActor
private func boardSpecs(
store: BoardStore,
search: BoardSearchPresentation = BoardSearchPresentation(),
zoom: BoardZoomStore? = nil,
appearance: AppearanceStore? = nil,
session: DragSession = DragSession(),
undo: BoardUndoManager? = nil
) -> [ToolbarItemSpec] {
BoardToolbar.specs(
store: store,
search: search,
zoom: zoom ?? makeZoom(),
appearance: appearance ?? makeAppearance(),
session: session,
undo: undo
)
}
/// A board stack with a real substrate behind it — the shape `BoardWindowHost` hands the toolbar
/// (`AppModel`'s session composes exactly this pair).
@MainActor
private func makeUndo(isReadOnly: @escaping @MainActor () -> Bool = { false }) -> (BoardUndoManager, NativeHistoryProvider) {
let provider = NativeHistoryProvider()
return (BoardUndoManager(history: provider, isReadOnly: isReadOnly), provider)
}
/// One step that records its crossings — `HistoryProviderTests`' synthetic fixture, in the one shape
/// this suite needs: what the toolbar pair must prove is that firing it *reaches* the stack, not what
/// the stack then does to disk.
@MainActor
private func countingStep(_ name: String, crossings: @escaping @MainActor () -> Void) -> HistoryStep {
HistoryStep(
name: name,
undo: { _ in crossings(); return .applied },
redo: { _ in crossings(); return .applied }
)
}
// MARK: - The vocabulary
/// **Toolbar item labels are menu titles minus a trailing ellipsis** (03-board-ui.md ▸ Toolbar) —
/// "one vocabulary everywhere, and the customize palette self-documents against the menus".
///
/// Pinned as a function rather than trusted per item because the rule's whole point is that nobody
/// spells a second name by hand: a menu row renamed without its toolbar item is two names for one
/// function, and the palette is exactly where a user compares them.
@Suite("Toolbar ▸ the label vocabulary")
struct ToolbarVocabularyTests {
@Test("A trailing ellipsis is dropped, in either spelling")
func trailingEllipsisIsDropped() {
// 03's own example: "macOS convention: 'Add Attachment…' labels as Add Attachment".
#expect(ToolbarVocabulary.label(menuTitle: "Add Attachment…") == "Add Attachment")
#expect(ToolbarVocabulary.label(menuTitle: "Add Attachment...") == "Add Attachment")
#expect(ToolbarVocabulary.label(menuTitle: "Style…") == "Style")
}
@Test("A title with no ellipsis is its own label, verbatim")
func plainTitlesSurviveUntouched() {
#expect(ToolbarVocabulary.label(menuTitle: "Show Trash") == "Show Trash")
#expect(ToolbarVocabulary.label(menuTitle: "New Card") == "New Card")
#expect(ToolbarVocabulary.label(menuTitle: "Raw Source") == "Raw Source")
}
@Test("Only a *trailing* ellipsis is a suffix; one inside the title is part of the name")
func interiorEllipsisIsNotStripped() {
#expect(ToolbarVocabulary.label(menuTitle: "Undo Move Card…") == "Undo Move Card")
#expect(ToolbarVocabulary.label(menuTitle: "Open… Recent") == "Open… Recent")
}
@Test("The space a stripped ellipsis leaves behind goes with it")
func trailingSpaceIsTrimmed() {
#expect(ToolbarVocabulary.label(menuTitle: "Empty Trash …") == "Empty Trash")
}
}
// MARK: - The board window's toolbar
/// The board toolbar's shipped defaults, its catalog, and the predicates its items mirror
/// (03-board-ui.md ▸ Toolbar).
@MainActor
@Suite("Toolbar ▸ the board window")
struct BoardToolbarTests {
@Test("The default set is the search field and Appearance, trailing, and nothing else")
func defaultsAreTheSearchFieldAndAppearance() {
// "Board window default: the search field and Appearance — both centered, immediately after
// the field." The flexible space ahead of them is what keeps the pair off the leading edge, so
// the *items* in the default set are exactly two.
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch, .boardAppearance])
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch, .boardAppearance])
}
@Test("The catalog is 03's eight commands plus the field — and the board popover is not in it")
func catalogIsTheDesignsInventory() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = boardSpecs(store: store)
// "Catalog (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
// Show Trash, Appearance" — plus the search field, which is a catalog item too (a user who
// removes it can put it back).
#expect(specs.map(\.identifier) == [
.boardNewCard,
.boardNewLane,
.boardZoomIn,
.boardZoomOut,
.boardUndo,
.boardRedo,
.boardShowTrash,
.boardAppearance,
.boardSearch,
])
// "The board popover deliberately has no toolbar item — the window-title widget is its
// committed home, and a second entry would muddy it." Absence is a settlement, so it is
// pinned by the exact-inventory assertion above and stated again here.
#expect(specs.count == 9)
}
/// The zoom pair is catalog-only — "the titlebar's default stays the search field alone"
/// (03-board-ui.md ▸ Toolbar). Stated separately from the default-set test because this is the
/// claim a future item is most likely to break by helpfully adding itself.
@Test("Zoom In and Zoom Out are available but never default")
func zoomIsCatalogOnly() {
#expect(!BoardToolbar.defaultItems.contains(.boardZoomIn))
#expect(!BoardToolbar.defaultItems.contains(.boardZoomOut))
}
/// There is deliberately no Actual Size item: it is a menu row only. Two buttons are the whole
/// of what a toolbar can usefully offer for a ladder with no readout — a third that resets is
/// titlebar clutter for a chord.
@Test("Actual Size has no toolbar item")
func actualSizeIsMenuOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(!boardSpecs(store: store).contains { $0.label == "Actual Size" })
}
/// Each button mirrors its menu row's ladder end, which is the same predicate `ZoomCommands`
/// disables on — one answer, two faces (03-board-ui.md ▸ Toolbar).
@Test("The zoom buttons disable at their own end of the ladder")
func zoomButtonsMirrorTheLadderEnds() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Every collaborator a spec's `isEnabled` interrogates lives in a local for the whole
// test, exactly as `store` already does: the closures capture them weakly — production
// hands them app-lived objects — so a temporary reads as uniformly disabled.
let session = DragSession()
let topZoom = makeZoom(level: BoardZoom.levels.last!)
let top = boardSpecs(store: store, zoom: topZoom, session: session)
#expect(try !#require(top.spec(.boardZoomIn)).isEnabled)
#expect(try #require(top.spec(.boardZoomOut)).isEnabled)
let bottomZoom = makeZoom(level: BoardZoom.levels.first!)
let bottom = boardSpecs(store: store, zoom: bottomZoom, session: session)
#expect(try #require(bottom.spec(.boardZoomIn)).isEnabled)
#expect(try !#require(bottom.spec(.boardZoomOut)).isEnabled)
let middleZoom = makeZoom()
let middle = boardSpecs(store: store, zoom: middleZoom, session: session)
#expect(try #require(middle.spec(.boardZoomIn)).isEnabled)
#expect(try #require(middle.spec(.boardZoomOut)).isEnabled)
}
/// Both are push buttons, and both go dead while a drag is in flight — the menu rows' guard,
/// reached through the very same predicate (`ZoomCommands.isEnabled`).
@Test("The zoom buttons are push buttons, and hold shut mid-drag")
func zoomButtonsHoldShutMidDrag() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Held in locals for the reason `zoomButtonsMirrorTheLadderEnds` spells out — and the
// dragging half asks with the same live zoom, so its disabled answer is the drag guard's
// rather than a dead weak reference's.
let zoom = makeZoom()
let restingSession = DragSession()
let resting = boardSpecs(store: store, zoom: zoom, session: restingSession)
for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] {
let spec = try #require(resting.spec(identifier))
#expect(spec.isOn == nil, "\(spec.label) is a push button, not a toggle")
#expect(spec.isEnabled)
}
let session = DragSession()
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
session.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44],
container: .board, source: store)
let dragging = boardSpecs(store: store, zoom: zoom, session: session)
for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] {
#expect(try !#require(dragging.spec(identifier)).isEnabled)
}
}
@Test("Every label is its menu row's title, and Undo/Redo keep static ones")
func labelsMatchTheMenus() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = boardSpecs(store: store)
#expect(specs.map(\.label) == [
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Appearance", "Search",
])
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels — the menu
// titles are rewritten dynamically ('Undo Move Card…'), which a toolbar label doesn't
// track". They are ordinary buttons since the command surface became the app's own
// (13-native-undo.md ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08) — the label
// exception survived the mechanism that motivated it, because it was never about *how* the
// item fires.
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
let spec = try #require(specs.spec(identifier))
guard case .button = spec.behavior else {
Issue.record("\(identifier.rawValue) must carry an explicit target over the session's stack")
return
}
#expect(spec.isOn == nil, "\(spec.label) is a push button, not a toggle")
}
}
/// **The pair's predicate is the menu rows' own object** (13-native-undo.md ▸ Rules ▸ the
/// command-surface bullet, re-ruled 2026-08-08): both surfaces read one `BoardUndoManager`, so
/// enablement here is `canUndo`/`canRedo` and nothing of the toolbar's own.
///
/// This replaces the nil-target pin the pair carried until that re-ruling — the responder-chain
/// route it named is unreachable on a SwiftUI window, and what took its place is a predicate this
/// suite can read directly rather than one only `NSWindow` could answer.
@Test("Undo and Redo mirror the session's stack, and firing them crosses it")
func undoPairCrossesTheSessionsStack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (manager, provider) = makeUndo()
let specs = boardSpecs(store: store, undo: manager)
let undo = try #require(specs.spec(.boardUndo))
let redo = try #require(specs.spec(.boardRedo))
#expect(!undo.isEnabled, "an empty stack dims it")
#expect(!redo.isEnabled)
var crossings: [String] = []
provider.register(countingStep("Move 3 Cards") { crossings.append("undo") })
#expect(undo.isEnabled, "a step on the stack lights it")
#expect(!redo.isEnabled, "and nothing has been crossed yet")
undo.activate()
#expect(crossings == ["undo"], "firing the item crosses the board's own stack")
#expect(!undo.isEnabled, "the stack is empty again")
#expect(redo.isEnabled, "and the crossed step is on the other one")
redo.activate()
#expect(crossings == ["undo", "undo"], "the synthetic step records both halves the same way")
#expect(undo.isEnabled)
}
/// The read-only lock disables the pair with every other mutating command, and it does it in the
/// one place it is decided — `BoardUndoManager.isReadOnly` (13-native-undo.md ▸ Rules ▸ locks;
/// 02-architecture.md § "The lock's scope"). The stack is untouched, which is why the items come
/// back when it clears.
@Test("The read-only lock dims the pair, steps and all")
func theLockDimsThePair() 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 (manager, provider) = makeUndo(isReadOnly: { lock.isOn })
provider.register(countingStep("Move Card") {})
provider.register(countingStep("Rename Lane") {})
provider.undo()
let specs = boardSpecs(store: store, undo: manager)
let undo = try #require(specs.spec(.boardUndo))
let redo = try #require(specs.spec(.boardRedo))
#expect(undo.isEnabled)
#expect(redo.isEnabled)
lock.isOn = true
#expect(!undo.isEnabled, "disabled with every other mutating command")
#expect(!redo.isEnabled)
#expect(provider.canUndo, "an enablement answer, not a clearing — the stack survives")
lock.isOn = false
#expect(undo.isEnabled, "and resumes when the lock clears")
#expect(redo.isEnabled)
}
/// A window whose session has gone hands the catalog `nil`, and the pair reads that as an empty
/// stack rather than as an error — the same quiet the adapter gives a board with no provider
/// (`BoardUndoManager.history`).
@Test("With no stack in front the pair is dim, and firing it does nothing")
func theUndoPairAbstainsWithNoStack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = boardSpecs(store: store, undo: nil)
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
let spec = try #require(specs.spec(identifier))
#expect(!spec.isEnabled)
spec.activate()
}
}
@Test("Every item's symbol resolves on this system")
func symbolsResolve() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
for spec in boardSpecs(store: store) {
guard let symbol = spec.symbol else { continue }
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
}
}
@Test("New Card mirrors its menu row: no lanes, no target, no item")
func newCardMirrorsItsRow() throws {
let populated = try makeBoard()
defer { populated.tearDown() }
let empty = try makeEmptyBoard()
defer { empty.tearDown() }
let store = try BoardStore(rootURL: populated.root)
let specs = boardSpecs(store: store)
let newCard = try #require(specs.spec(.boardNewCard))
#expect(newCard.isEnabled)
#expect(newCard.isOn == nil, "New Card is a push button, not a toggle")
let emptyStore = try BoardStore(rootURL: empty.root)
let emptySpecs = boardSpecs(store: emptyStore)
let disabled = try #require(emptySpecs.spec(.boardNewCard))
#expect(!disabled.isEnabled, "the zero-lane board disables the row, so it disables the item")
#expect(emptyStore.newCardTarget == nil, "one predicate, read by both")
}
@Test("The toolbar is customizable, and every catalog item actually builds")
func everyItemBuilds() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
// 03's three customization sentences: "right-click ▸ Customize Toolbar…, drag to rearrange,
// system overflow and icon/text display options".
#expect(controller.toolbar.allowsUserCustomization)
#expect(controller.toolbar.allowsDisplayModeCustomization)
#expect(controller.toolbar.autosavesConfiguration, "the arrangement is the user's, and it keeps")
let allowed = controller.toolbarAllowedItemIdentifiers(controller.toolbar)
#expect(allowed.contains(.flexibleSpace) && allowed.contains(.space), "the palette's spacers")
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == BoardToolbar.defaultItems)
for spec in boardSpecs(store: store) {
let item = try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: spec.identifier,
willBeInsertedIntoToolbar: true
))
#expect(item.label == spec.label)
#expect(item.paletteLabel == spec.label, "one vocabulary, in the palette too")
}
}
@Test("The toolbar's search item is the field's home; the palette's copy is inert")
func searchItemOwnsTheField() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
#expect(search.focusField == nil, "nothing to focus until the item exists")
// `NSSearchToolbarItem.view` is unavailable — the item owns its layout — so the field is
// reached through `searchField`, which is also where it is handed in.
let palette = try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: .boardSearch,
willBeInsertedIntoToolbar: false
) as? NSSearchToolbarItem)
#expect(!palette.searchField.isEnabled, "the palette's copy is a picture, not a second field")
#expect(search.focusField == nil, "a palette copy must not claim ⌘F's handle")
let installed = try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: .boardSearch,
willBeInsertedIntoToolbar: true
) as? NSSearchToolbarItem)
let field = installed.searchField
#expect(field.isEnabled, "the real one takes typing")
#expect(search.focusField != nil, "the installed item is the field's home")
// The live field writes through per keystroke, which is the m5 contract this milestone
// moved rather than changed (`BoardSearchFieldController`).
field.stringValue = "spec"
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
#expect(store.searchQuery == "spec")
}
@Test("The search item is AppKit's own, configured for grow-on-focus and a staged Escape")
func searchItemIsTheStockOne() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
let item = try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: .boardSearch,
willBeInsertedIntoToolbar: true
) as? NSSearchToolbarItem)
// The em-based figure is the *focused* width: `NSSearchToolbarItem` applies its preferred
// width "whenever it gets the keyboard focus", and the resting width is the item's own
// (10-accessibility.md ▸ Text scaling: the number is characters, never points).
#expect(
item.preferredWidthForSearchField
== BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize)
)
// **Escape is staged** (04-interactions.md ▸ Search, settled: "in a non-empty field it
// clears the query, focus staying in the field"). AppKit's default cancel button clears
// *and* resigns, which would collapse that first step into the second.
#expect(!item.resignsFirstResponderWithCancel)
// The overflow row is the item's, and assigning here — `nil` included — destroys it. A
// custom-view item had to supply one; this one must not.
#expect(item.menuFormRepresentation != nil, "the item ships a live overflow row")
// Its own priority already sits above `.high`, so the nudge a custom-view item needed
// would be a demotion.
#expect(item.visibilityPriority.rawValue > NSToolbarItem.VisibilityPriority.high.rawValue)
}
@Test("⌘F answers false for a field no window can give the keyboard to")
func focusHandleAnswersForAnUnrootedField() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
_ = controller.toolbar(
controller.toolbar,
itemForItemIdentifier: .boardSearch,
willBeInsertedIntoToolbar: true
)
// The item exists, so the handle does. Nothing has put it in a window — the same shape the
// system overflow leaves the field in — so ⌘F must fall through to the transient strip
// rather than claim a focus it did not get (`BoardSearchPresentation.focusField`).
let focusField = try #require(search.focusField)
#expect(focusField() == false)
search.invokeSearch()
#expect(search.isTransient, "⌘F always summons search")
}
@Test("Show Trash is a toggle whose state is the View menu's checkmark")
func showTrashTogglesTheQuasiLane() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = boardSpecs(store: store)
let showTrash = try #require(specs.spec(.boardShowTrash))
#expect(showTrash.isOn == false, "hidden by default, like the menu row's checkmark")
showTrash.activate()
#expect(store.transient.isTrashVisible, "the item drives the row's own setter")
#expect(showTrash.isOn == true)
showTrash.activate()
#expect(!store.transient.isTrashVisible)
#expect(showTrash.isOn == false)
}
/// The picker's whole state contract, `showTrashTogglesTheQuasiLane`'s shape turned toward
/// `.picker`: no single `isOn`, so the checkmark and the write path are the item's own
/// `selected`/`select` closures rather than `.isOn`/`.activate()`.
@Test("Appearance is a picker whose selected row tracks the store and whose rows write it")
func appearanceTracksTheStore() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let appearance = makeAppearance()
let specs = boardSpecs(store: store, appearance: appearance)
let item = try #require(specs.spec(.boardAppearance))
#expect(item.isOn == nil, "a picker has no single on-state")
#expect(item.isEnabled, "always enabled — no board state gates an appearance override")
guard case let .picker(options, selected, select) = item.behavior else {
Issue.record("Appearance is not a picker")
return
}
#expect(options.map(\.title) == ["Auto", "Light", "Dark"])
#expect(selected() == 0, "Auto by default, like the store's own nil override")
select(1)
#expect(appearance.override == .light, "the row drives the store's own setter")
#expect(selected() == 1)
select(2)
#expect(appearance.override == .dark)
#expect(selected() == 2)
select(0)
#expect(appearance.override == nil)
#expect(selected() == 0)
// `.activate()` is a no-op for a picker — firing lives in the dropdown's own rows.
item.activate()
#expect(appearance.override == nil, "activate() does not move the selection")
}
}
// MARK: - The card window's toolbar
/// The card toolbar's six items: the trio's two state clauses (Edit Body's on-state and its
/// raw-source disable, Add Attachment staying live in every mode), Show Sidebar, and — since the
/// sidebar's Actions section retired (Pipeline card bcd3b323) — Delete Card and Reveal in Finder.
@MainActor
@Suite("Toolbar ▸ the card window")
struct CardToolbarTests {
/// The four window-scoped handles a card window's toolbar reads, wired as the host wires them.
private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments, CardWindowActions) {
let body = CardBodyPresentation()
let raw = CardRawSourceSession()
raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") }
raw.apply = { _ in .applied }
let attachments = CardAttachments()
attachments.isEditable = true
attachments.cardFolder = URL(filePath: "/tmp/board/lane/card")
let actions = CardWindowActions()
actions.isDeletable = true
return (body, raw, attachments, actions)
}
@Test("The default set is the trio, Delete Card, and Show Sidebar — separated by a flexible space")
func defaultsAreSeparatedFromDeleteCard() {
// "Card window default: Edit Body · Raw Source · Add Attachment" joined by Delete Card (after
// the sidebar Actions section retired), then Show Sidebar (the trailing-sidebar toggle, at the
// rightmost position per macOS convention), with `.flexibleSpace` between the creation-side
// defaults and the destructive/sidebar-control cluster — `BoardToolbar.defaultItems`' own
// leading-spacer pattern, turned trailing here.
#expect(CardToolbar.defaultItems == [
.cardEditBody, .cardRawSource, .cardAddAttachment, .flexibleSpace, .cardDeleteCard, .cardShowSidebar,
])
#expect(CardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [
.cardEditBody, .cardRawSource, .cardAddAttachment, .cardDeleteCard, .cardShowSidebar,
])
}
@Test("Reveal in Finder is catalog-only — it already has a menu row with no default chord")
func revealInFinderIsCatalogOnly() {
#expect(!CardToolbar.defaultItems.contains(.cardRevealInFinder))
}
@Test("The catalog is the six items, in this file's order, labeled off their menu titles")
func catalogIsTheSixItems() {
let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
#expect(specs.map(\.identifier) == [
.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar, .cardDeleteCard, .cardRevealInFinder,
])
#expect(specs.map(\.label) == [
"Edit Body", "Raw Source", "Add Attachment", "Show Sidebar", "Delete Card", "Reveal in Finder",
])
}
@Test("Every item's symbol resolves on this system")
func symbolsResolve() {
let (body, raw, attachments, actions) = makeHandles()
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) {
guard let symbol = spec.symbol else { continue }
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
}
}
@Test("Every catalog item actually builds, and the toolbar is customizable")
func everyItemBuilds() throws {
let (body, raw, attachments, actions) = makeHandles()
let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments, actions: actions)
#expect(controller.toolbar.allowsUserCustomization)
#expect(controller.toolbar.allowsDisplayModeCustomization)
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == CardToolbar.defaultItems)
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) {
let item = try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: spec.identifier,
willBeInsertedIntoToolbar: true
))
#expect(item.label == spec.label)
#expect(item.paletteLabel == spec.label)
}
}
@Test("Edit Body is a single toggle showing on-state in Edit")
func editBodyShowsItsMode() {
let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let editBody = specs.spec(.cardEditBody) else {
Issue.record("no Edit Body item")
return
}
#expect(editBody.isOn == false, "Preview is the window's opening mode")
editBody.activate()
#expect(body.mode == .edit, "the item drives the same flip the ⌘E row does")
#expect(editBody.isOn == true)
editBody.activate()
#expect(body.mode == .preview)
#expect(editBody.isOn == false)
}
@Test("Raw Source active disables Edit Body — the row's own predicate, mirrored")
func rawSourceDisablesEditBody() {
let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let editBody = specs.spec(.cardEditBody), let rawSource = specs.spec(.cardRawSource) else {
Issue.record("the card toolbar is missing an item")
return
}
#expect(editBody.isEnabled)
#expect(rawSource.isOn == false)
rawSource.activate()
#expect(raw.isActive, "the item enters source mode exactly as ⌥⌘E does")
#expect(rawSource.isOn == true, "a toggle showing on-state")
// "While source mode is active, Edit Body disables (Cancel/Apply own the exits)" — and the
// predicate is `EditBodyCommand.isEnabled`, handed to the item rather than restated.
#expect(!editBody.isEnabled)
#expect(editBody.isEnabled == EditBodyCommand.isEnabled(body: body, rawSource: raw))
raw.cancel()
#expect(editBody.isEnabled)
#expect(rawSource.isOn == false)
}
@Test("Add Attachment stays enabled in every mode, including an open raw edit")
func addAttachmentIsAlwaysAvailable() {
let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let addAttachment = specs.spec(.cardAddAttachment) else {
Issue.record("no Add Attachment item")
return
}
#expect(addAttachment.isEnabled)
// "Add Attachment stays enabled in every mode — attachment operations never touch
// `index.md`, so they're safe alongside a raw edit" (03 ▸ Toolbar; 01-storage-format.md
// makes the same point from the stamping side).
raw.enter()
#expect(raw.isActive)
#expect(addAttachment.isEnabled)
body.setMode(.edit)
#expect(addAttachment.isEnabled)
// The lock is the row's own predicate, and the item inherits it whole.
attachments.isEditable = false
#expect(!addAttachment.isEnabled)
#expect(addAttachment.isEnabled == AddAttachmentCommand.isEnabled(attachments))
}
/// **Show Sidebar is always enabled and drives its own injected write path** — the two closures
/// `CardToolbar.specs` defaults to `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, held
/// here over a local `Bool` instead so this test never touches the developer's own
/// `UserDefaults.standard` domain (`CardSessionUndoTests`' quick-style caution, applied to the one
/// item here with no window-scoped handle to hold a scratch value instead).
@Test("Show Sidebar toggles its own bit, always enabled, on-state matching the read")
func showSidebarTogglesItsOwnBit() {
let (body, raw, attachments, actions) = makeHandles()
var shown = true
let specs = CardToolbar.specs(
body: body,
rawSource: raw,
attachments: attachments,
actions: actions,
isSidebarShown: { shown },
setSidebarShown: { shown = $0 }
)
guard let showSidebar = specs.spec(.cardShowSidebar) else {
Issue.record("no Show Sidebar item")
return
}
#expect(showSidebar.isOn == true)
#expect(showSidebar.isEnabled, "showing or hiding a pane is not a mutation the read-only lock gates")
showSidebar.activate()
#expect(shown == false, "the item drives the same bit the injected closures read")
#expect(showSidebar.isOn == false)
showSidebar.activate()
#expect(shown == true)
#expect(showSidebar.isOn == true)
// Raw source and the read-only lock are both the *other* items' predicates — Show Sidebar
// reads neither.
raw.enter()
attachments.isEditable = false
#expect(showSidebar.isEnabled)
}
/// **Delete Card is the sidebar Actions button's former write, on a push button** — gated by the
/// same read-only predicate (`CardWindowActions.isDeletable`, wired from `!store.isReadOnly`
/// exactly as the sidebar button's `.disabled(store.isReadOnly)` was), and firing calls the same
/// closure the host wires from `BoardStore.deleteCard(_:)` (`CardWindowHost.configureActions`).
@Test("Delete Card is a push button, gated by the read-only lock, that fires the wired delete")
func deleteCardFiresTheWiredDelete() {
let (body, raw, attachments, actions) = makeHandles()
var deleted = 0
actions.deleteCard = { deleted += 1 }
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let deleteCard = specs.spec(.cardDeleteCard) else {
Issue.record("no Delete Card item")
return
}
#expect(deleteCard.isOn == nil, "Delete Card is a push button, not a toggle")
#expect(deleteCard.isEnabled)
deleteCard.activate()
#expect(deleted == 1, "the item fires the same write the host wires from BoardStore.deleteCard")
// The read-only lock is the sidebar button's own predicate, carried over whole.
actions.isDeletable = false
#expect(!deleteCard.isEnabled)
#expect(deleteCard.isEnabled == DeleteCardCommand.isEnabled(actions))
}
/// **Reveal in Finder computes exactly what `RevealInFinderCommand`'s card-window branch does** —
/// same function (`CardAttachments.revealURLs`), so the two surfaces can never disagree. Firing it
/// is not exercised here, `addAttachmentIsAlwaysAvailable`'s own restraint: both open a real
/// system surface (a panel there, Finder here), which a unit test does not drive.
@Test("Reveal in Finder is enabled exactly when the row's own computation finds something to reveal")
func revealInFinderMirrorsTheRowsComputation() {
let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let reveal = specs.spec(.cardRevealInFinder) else {
Issue.record("no Reveal in Finder item")
return
}
// `makeHandles()` gives the section a folder and no focus — the card-folder branch.
#expect(reveal.isEnabled)
#expect(!CardAttachments.revealURLs(
cardFolder: attachments.cardFolder, selectedURL: attachments.selectedURL, isSectionFocused: attachments.isFocused
).isEmpty)
// A window on its way out — no folder, nothing to reveal — is the row's own disabled clause.
attachments.cardFolder = nil
#expect(!reveal.isEnabled)
}
}
// MARK: - The toolbar survives a stale dismantle
/// **The malformed-titlebar bug**: toggling a card window between Edit and Raw Source could leave it
/// with no toolbar at all — and losing the toolbar is what collapses AppKit's two-line title-and-
/// subtitle down to its single combined line, the "⟨title⟩ — ⟨board⟩ ⟨lane⟩" strip a user reported
/// seeing after exactly that toggle.
///
/// The root cause lived in `HostedWindowController.attach`/`detach`, not in the card window's own
/// chrome code: `WindowAccessor`'s own doc comment already recorded that SwiftUI "dismantles and
/// re-makes the background representable" on macOS 26, and a content swap deep in the window's tree
/// (raw source replacing the whole content area is the one this traces to) can trigger exactly that —
/// but the dismantle for the *old* representable and the attach for the *new* one were never
/// guaranteed to arrive in the order they logically pair in. When the stale dismantle landed **after**
/// the fresh attach, an identity-blind `detach()` tore the toolbar (and the titlebar accessory, and the
/// delegate proxy) right back off a window a newer attach had just finished configuring — silently,
/// with nothing to put it back until something else attached again.
///
/// `attach(to:through:)`/`detach(through:)` fix this by tracking *which* `WindowAccessor` view is the
/// current owner and refusing a detach for any other — see `HostedWindowController.attachedThroughView`.
/// These tests drive that seam directly, standing in for the two views SwiftUI would otherwise recreate.
@MainActor
@Suite("Toolbar ▸ survives a stale dismantle")
struct ToolbarStaleDismantleTests {
private func makeWindow() -> NSWindow {
NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 400),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: true
)
}
/// A real, installable card toolbar — the exact shape `CardWindowHost.configureWindow` hands
/// `installToolbar`, built fresh rather than borrowed from `CardToolbarTests` (`private` there,
/// on purpose: a struct's own fixtures are not a second suite's to reach into).
private func makeToolbar() -> WindowToolbarController {
CardToolbar.controller(
body: CardBodyPresentation(),
rawSource: CardRawSourceSession(),
attachments: CardAttachments(),
actions: CardWindowActions()
)
}
@Test("A dismantle for a view a newer attach has already superseded does not remove the toolbar")
func aStaleDismantleIsRefused() {
let window = makeWindow()
let controller = HostedWindowController()
let toolbarController = makeToolbar()
controller.installToolbar(toolbarController)
let firstView = NSView()
let secondView = NSView()
// The normal-order half: the first mount installs the toolbar.
controller.attach(to: window, through: firstView)
#expect(window.toolbar === toolbarController.toolbar)
// A second `WindowAccessor` instance mounts over the *same* window — the racy content-swap
// case, standing in for SwiftUI recreating the representable without the window itself
// changing. It becomes the new owner.
controller.attach(to: window, through: secondView)
#expect(window.toolbar === toolbarController.toolbar, "the still-current window keeps its toolbar")
// The first view's teardown arrives *after* the second view's attach — the ordering
// `WindowAccessor`'s own doc comment says is not guaranteed. A stale detach for a superseded
// view must not undo what the fresher attach just did.
controller.detach(through: firstView)
#expect(window.toolbar === toolbarController.toolbar, "a stale dismantle must not remove the toolbar")
#expect(window.delegate === controller, "nor hand the delegate back to SwiftUI's own")
}
@Test("The legitimate owner's dismantle still tears the toolbar down")
func theCurrentOwnersDismantleStillWorks() {
let window = makeWindow()
let controller = HostedWindowController()
let toolbarController = makeToolbar()
controller.installToolbar(toolbarController)
let view = NSView()
controller.attach(to: window, through: view)
#expect(window.toolbar === toolbarController.toolbar)
controller.detach(through: view)
#expect(window.toolbar == nil, "the view that actually owns the attachment can still tear it down")
}
@Test("The normal order — detach, then attach — reinstalls the toolbar exactly as before")
func theOrdinaryOrderStillSelfHeals() {
let window = makeWindow()
let controller = HostedWindowController()
let toolbarController = makeToolbar()
controller.installToolbar(toolbarController)
let firstView = NSView()
controller.attach(to: window, through: firstView)
controller.detach(through: firstView)
#expect(window.toolbar == nil)
let secondView = NSView()
controller.attach(to: window, through: secondView)
#expect(window.toolbar === toolbarController.toolbar, "the next attach reinstalls it")
}
@Test("A caller that names no view keeps the original, unconditional attach/detach")
func viewlessCallsAreUnaffected() {
// `BoardChromeTests` and every other direct caller in this test target attaches and detaches
// without a view — the shape `attach(to:)`/`detach()` always had. That pair must keep working
// exactly as before: the view-identity guard only ever engages when both sides name one.
let window = makeWindow()
let controller = HostedWindowController()
let toolbarController = makeToolbar()
controller.installToolbar(toolbarController)
controller.attach(to: window)
#expect(window.toolbar === toolbarController.toolbar)
controller.detach()
#expect(window.toolbar == nil)
}
}
// MARK: - ⌘F and the search field's two homes
/// "⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the
/// field transiently until the search clears" (03-board-ui.md ▸ Toolbar).
///
/// The decision and the dismissal are pure functions on `BoardSearchPresentation` precisely so this
/// clause is testable without a toolbar, a window, or a first responder — none of which a unit test
/// can stand up honestly.
@MainActor
@Suite("Toolbar ▸ ⌘F's transient fallback")
struct BoardSearchSurfacingTests {
@Test("Installed, ⌘F focuses the toolbar's field; removed, it surfaces the transient one")
func invocationFollowsTheField() {
#expect(
BoardSearchPresentation.invocation(isInstalledInToolbar: true, isTransient: false)
== .focusToolbarField
)
#expect(
BoardSearchPresentation.invocation(isInstalledInToolbar: true, isTransient: true)
== .focusToolbarField,
"the item is the field's home whenever it exists"
)
#expect(
BoardSearchPresentation.invocation(isInstalledInToolbar: false, isTransient: false)
== .surfaceTransiently
)
#expect(
BoardSearchPresentation.invocation(isInstalledInToolbar: false, isTransient: true)
== .focusTransientField,
"a second ⌘F re-focuses rather than re-surfacing"
)
}
@Test("The transient surface lasts until the search clears — and never mid-edit")
func transientLifetime() {
// A query still filtering the board keeps it, focused or not: Tab is the keep-filter path,
// and the field has to stay reachable while the filter stands (04 § Search).
#expect(BoardSearchPresentation.transientPersists(query: "spec", isFocused: false))
#expect(BoardSearchPresentation.transientPersists(query: "spec", isFocused: true))
// An emptied field the user is still typing in keeps it too — otherwise deleting back to
// nothing would yank the field out from under the caret.
#expect(BoardSearchPresentation.transientPersists(query: "", isFocused: true))
// Cleared and unfocused: the search is over.
#expect(!BoardSearchPresentation.transientPersists(query: "", isFocused: false))
}
@Test("With the item installed, ⌘F focuses it and raises nothing")
func installedFieldIsFocusedInPlace() {
let presentation = BoardSearchPresentation()
var focused = 0
presentation.focusField = {
focused += 1
return true
}
presentation.invokeSearch()
#expect(focused == 1)
#expect(!presentation.isTransient, "the titlebar stays the field's home")
}
@Test("With the item removed, ⌘F raises the strip and the arriving field claims the keyboard")
func removedFieldSurfacesTransiently() {
let presentation = BoardSearchPresentation()
presentation.isInstalledInToolbar = false
presentation.focusField = {
Issue.record("the toolbar has no field to focus")
return false
}
presentation.invokeSearch()
#expect(presentation.isTransient)
// The field does not exist yet — the host renders it on the next update — so the focus is a
// claim the field takes as it appears, exactly once.
#expect(presentation.consumeFocusOnAppear())
#expect(!presentation.consumeFocusOnAppear())
// Now it exists: a second ⌘F focuses it in place.
var focused = 0
presentation.focusTransientField = { focused += 1 }
presentation.invokeSearch()
#expect(focused == 1)
#expect(presentation.isTransient)
}
@Test("The strip goes when the search clears, and stays while it has not")
func transientDismissal() {
let presentation = BoardSearchPresentation()
presentation.isInstalledInToolbar = false
presentation.invokeSearch()
presentation.focusTransientField = {}
#expect(presentation.isTransient)
presentation.isFocused = true
presentation.dismissTransientIfCleared(query: "")
#expect(presentation.isTransient, "the keyboard is still in it")
presentation.isFocused = false
presentation.dismissTransientIfCleared(query: "spec")
#expect(presentation.isTransient, "the filter is still standing")
presentation.dismissTransientIfCleared(query: "")
#expect(!presentation.isTransient)
#expect(presentation.focusTransientField == nil, "the handle goes with the field")
}
@Test("An installed field that cannot take the keyboard falls back to the strip")
func overflowedFieldFallsBackToTheStrip() {
// The item is installed but in the system overflow, where it is in no window and cannot
// become first responder. "⌘F always summons search", so the strip stands in — an item the
// user cannot type into is a removal by another name.
let presentation = BoardSearchPresentation()
presentation.focusField = { false }
presentation.invokeSearch()
#expect(presentation.isTransient)
#expect(presentation.consumeFocusOnAppear())
}
@Test("A field in the toolbar has no transient surface to dismiss")
func installedFieldIgnoresDismissal() {
let presentation = BoardSearchPresentation()
#expect(presentation.isInstalledInToolbar, "the shipped default")
presentation.focusField = { true }
presentation.invokeSearch()
presentation.dismissTransientIfCleared(query: "")
#expect(!presentation.isTransient)
}
}
// MARK: - The field's two intercepted keys
/// An `NSSearchToolbarItem` that counts the two interactions the field drives — the only way to see
/// grow-on-focus and its undo without a window, since both are AppKit's own animation underneath.
private final class RecordingSearchItem: NSSearchToolbarItem {
var interactionsBegun = 0
var interactionsEnded = 0
override func beginSearchInteraction() {
interactionsBegun += 1
super.beginSearchInteraction()
}
override func endSearchInteraction() {
interactionsEnded += 1
super.endSearchInteraction()
}
}
/// **Escape is staged and Return is swallowed** (04-interactions.md ▸ Search, settled) — the two keys
/// the field gives meanings of its own, and the only two: "every key with the field focused acts on
/// the field — stock `NSSearchField` behavior, no pass-throughs".
@MainActor
@Suite("Toolbar ▸ the search field's two keys")
struct BoardSearchFieldKeyTests {
/// The field as its toolbar home builds it, adopted by an item that records what it is asked to
/// do — `BoardToolbar`'s wiring, with the item swapped for one that can be read.
private func makeToolbarField(
store: BoardStore,
presentation: BoardSearchPresentation
) -> (NSSearchField, RecordingSearchItem) {
let field = BoardSearchFieldController.makeField(
store: store,
presentation: presentation,
home: .toolbar
)
let item = RecordingSearchItem(itemIdentifier: .boardSearch)
item.searchField = field
BoardSearchFieldController.adopt(item, presentation: presentation)
return (field, item)
}
private func send(_ selector: Selector, to field: NSSearchField) -> Bool? {
field.delegate?.control?(field, textView: NSTextView(), doCommandBy: selector)
}
@Test("A non-empty field takes Escape as a clear, and keeps the keyboard")
func escapeClearsBeforeItLeaves() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let presentation = BoardSearchPresentation()
var handedBack = 0
presentation.focusBoard = { handedBack += 1 }
let (field, item) = makeToolbarField(store: store, presentation: presentation)
field.stringValue = "spec"
field.delegate?.controlTextDidChange?(
Notification(name: NSControl.textDidChangeNotification, object: field)
)
#expect(store.searchQuery == "spec")
#expect(send(#selector(NSResponder.cancelOperation(_:)), to: field) == true)
// "In a non-empty field it clears the query, focus staying in the field."
#expect(store.searchQuery.isEmpty)
#expect(field.stringValue.isEmpty)
#expect(handedBack == 0, "one press, one layer")
#expect(item.interactionsEnded == 0, "the field keeps the keyboard, so it keeps its width")
}
@Test("An empty field takes Escape as an exit — the board gets the keyboard, the field its width")
func escapeHandsTheKeyboardBack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let presentation = BoardSearchPresentation()
var handedBack = 0
presentation.focusBoard = { handedBack += 1 }
let (field, item) = makeToolbarField(store: store, presentation: presentation)
#expect(send(#selector(NSResponder.cancelOperation(_:)), to: field) == true)
// "In an empty field it returns focus to the board" — and the grown field settles back with
// the keyboard it just gave up.
#expect(handedBack == 1)
#expect(item.interactionsEnded == 1)
}
@Test("The transient home takes the same Escape with no item to collapse")
func escapeInTheStripHasNoWidthToUndo() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let presentation = BoardSearchPresentation()
var handedBack = 0
presentation.focusBoard = { handedBack += 1 }
let field = BoardSearchFieldController.makeField(
store: store,
presentation: presentation,
home: .transient
)
#expect(send(#selector(NSResponder.cancelOperation(_:)), to: field) == true)
#expect(handedBack == 1, "one implementation of the staircase, whichever home it is in")
}
@Test("Return is a swallowed no-op, and every other key falls through to the field editor")
func returnIsSwallowedAndTheRestPassThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let presentation = BoardSearchPresentation()
let (field, _) = makeToolbarField(store: store, presentation: presentation)
field.stringValue = "spec"
field.delegate?.controlTextDidChange?(
Notification(name: NSControl.textDidChangeNotification, object: field)
)
// "The filter is live, there is nothing to submit — it never reaches the board's
// rename/create grammar."
#expect(send(#selector(NSResponder.insertNewline(_:)), to: field) == true)
#expect(store.searchQuery == "spec", "a swallowed key changes nothing")
// "Stock NSSearchField behavior, no pass-throughs": the field editor keeps everything else,
// caret motion and text selection included.
#expect(send(#selector(NSResponder.moveLeft(_:)), to: field) == false)
#expect(send(#selector(NSResponder.deleteBackward(_:)), to: field) == false)
#expect(send(#selector(NSResponder.moveUp(_:)), to: field) == false)
}
@Test("⌘F expands and focuses in one call — and claims nothing when the field is in no window")
func focusHandleIsTheItemsInteraction() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let presentation = BoardSearchPresentation()
let (_, item) = makeToolbarField(store: store, presentation: presentation)
let focusField = try #require(presentation.focusField)
#expect(focusField() == false, "a field in no window cannot take the keyboard")
#expect(item.interactionsBegun == 0, "and must not report a focus it never took")
}
}