Files
lanework/KanbanTests/ToolbarTests.swift
T
rzen 06ee59e24b Implement toolbar customization for both windows
NSToolbar through the existing HostedWindowController rather than
SwiftUI's toolbar — for reasons that are contract, not taste: 03's
transient-search clause is a decision over the toolbar's current
contents, which NSToolbar publishes and SwiftUI's API cannot answer;
Undo/Redo are the system's nil-target responder-chain actions so the
toolbar items validate exactly as the menu rows do (disabled on base
boards, alive in m8 unchanged); and the search item hosts the real
NSSearchField with explicit first-responder control. Customization is
all system furniture — Customize sheet, drag rearrange, display-mode
popup, overflow, autosaved per window kind. Board default: the search
field alone, trailing; catalog adds New Card, New Lane, Undo, Redo,
Show Trash, every action extracted from its menu command so no second
predicate exists. Card default: the Edit Body / Raw Source toggles and
Add Attachment, mirroring their commands' own predicates live via
observation tracking. Removing the search item keeps the promise —
⌘F surfaces the same field as a transient strip under the title bar,
persisting until the query clears, and an overflowed item that cannot
take the keyboard falls through to the strip too.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 12:57:42 -04:00

517 lines
23 KiB
Swift

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 }
}
}
// 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, trailing, and nothing else")
func defaultsAreTheSearchFieldAlone() {
// "Board window default: the search field, nothing else — trailing, the one default item;
// the titlebar stays clean." The flexible space ahead of it is what "trailing" means to
// NSToolbar, so the *items* in the default set are exactly one.
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch])
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch])
}
@Test("The catalog is 03's five 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 = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
// "Catalog (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash" — 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,
.boardUndo,
.boardRedo,
.boardShowTrash,
.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 == 6)
}
@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 = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
#expect(specs.map(\.label) == ["New Card", "New Lane", "Undo", "Redo", "Show Trash", "Search"])
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels —
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card…'), which a toolbar
// label doesn't track". They are also the two items with no action of their own: nil target,
// responder-chain selectors, "matching their menu items" by using the same lookup.
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
let spec = try #require(specs.spec(identifier))
guard case let .responderAction(selector) = spec.behavior else {
Issue.record("\(identifier.rawValue) must reach the responder chain like its menu row")
return
}
#expect(selector == NSSelectorFromString(spec.label.lowercased() + ":"))
}
}
@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 BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
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 = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
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 = BoardToolbar.specs(store: emptyStore, search: BoardSearchPresentation())
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)
// 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 BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
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)
#expect(search.focusField == nil, "nothing to focus until the item exists")
let palette = try #require(controller.toolbar(
controller.toolbar,
itemForItemIdentifier: .boardSearch,
willBeInsertedIntoToolbar: false
))
#expect(palette.view is NSSearchField)
#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
))
let field = try #require(installed.view as? NSSearchField)
#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("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 = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
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)
}
}
// MARK: - The card window's toolbar
/// The card toolbar's trio, and the two state clauses 03 states about it: Edit Body's on-state and
/// its raw-source disable, and Add Attachment staying live in every mode.
@MainActor
@Suite("Toolbar ▸ the card window")
struct CardToolbarTests {
/// The three window-scoped handles a card window's toolbar reads, wired as the host wires them.
private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments) {
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")
return (body, raw, attachments)
}
@Test("The default set is the whole catalog — the trio, in 03's order")
func defaultsAreTheCatalog() {
let (body, raw, attachments) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
// "Card window default: Edit Body · Raw Source · Add Attachment … the catalog is the same
// trio."
#expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment])
#expect(specs.map(\.identifier) == CardToolbar.defaultItems)
#expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment"])
}
@Test("Every item's symbol resolves on this system")
func symbolsResolve() {
let (body, raw, attachments) = makeHandles()
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) {
guard let symbol = spec.symbol else { continue }
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
}
}
@Test("Every item of the trio actually builds, and the toolbar is customizable")
func everyItemBuilds() throws {
let (body, raw, attachments) = makeHandles()
let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments)
#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) {
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) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
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) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
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) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
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))
}
}
// 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)
}
}