View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
831 lines
37 KiB
Swift
831 lines
37 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
|
|
}
|
|
|
|
/// The board catalog, with the two collaborators every test here supplies the same way: a fresh zoom
|
|
/// store and a drag session with nothing in flight.
|
|
@MainActor
|
|
private func boardSpecs(
|
|
store: BoardStore,
|
|
search: BoardSearchPresentation = BoardSearchPresentation(),
|
|
zoom: BoardZoomStore? = nil,
|
|
session: DragSession = DragSession()
|
|
) -> [ToolbarItemSpec] {
|
|
BoardToolbar.specs(store: store, search: search, zoom: zoom ?? makeZoom(), 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, 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 seven 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" — 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,
|
|
.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 == 8)
|
|
}
|
|
|
|
/// 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", "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(), 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(), 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(), 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(), 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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|