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") } }