View ▸ Appearance (11-command-nexus.md): three radio-exclusive rows, app-wide, persisted, needing no window in front — the View menu's new last group. AppearanceStore owns the override's rules (absent key = Auto, lenient reads degrade to Auto, remove-at-default) with an injectable apply seam so test hosts never touch NSApp; the one real apply hands NSApp.appearance its answer in applicationDidFinishLaunching, the global side effect KanbanApp.init must not carry. The board toolbar gains its first .picker item — an NSMenuToolbarItem whose rows re-fetch their spec fresh, checkmark read at menu-open like every other menu row — and Appearance joins the search field as the second default item, centered beside it (03-board-ui.md ▸ Toolbar, ratified 2026-08-07). Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
887 lines
40 KiB
Swift
887 lines
40 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 }
|
|
}
|
|
}
|
|
|
|
/// 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 three collaborators every test here supplies the same way: a fresh
|
|
/// zoom store, a fresh appearance store, and a drag session with nothing in flight.
|
|
@MainActor
|
|
private func boardSpecs(
|
|
store: BoardStore,
|
|
search: BoardSearchPresentation = BoardSearchPresentation(),
|
|
zoom: BoardZoomStore? = nil,
|
|
appearance: AppearanceStore? = nil,
|
|
session: DragSession = DragSession()
|
|
) -> [ToolbarItemSpec] {
|
|
BoardToolbar.specs(
|
|
store: store,
|
|
search: search,
|
|
zoom: zoom ?? makeZoom(),
|
|
appearance: appearance ?? makeAppearance(),
|
|
session: session
|
|
)
|
|
}
|
|
|
|
// 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 —
|
|
// 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 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())
|
|
|
|
// 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())
|
|
|
|
#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())
|
|
|
|
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())
|
|
|
|
_ = 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 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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|