From ac5ac4c2cd72a24e47dbd510d88cbfb51381f24a Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 07:02:27 -0400 Subject: [PATCH] Pin the caret-chord yielding seam with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-28 realign card asks the four caret chords (Move Left/Right, lane-width pair) to yield to any focused text control. The seam already conforms — caretChordsYield covers the board popover, the search field's first-responder tracking, and card windows publish no scope — but had zero regression coverage. CaretChordTests pins the rule: scopeless windows never yield, either surface suffices, and inline editors remain acceptsBoardMutations' concern, not this seam's. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- KanbanTests/CaretChordTests.swift | 103 ++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 KanbanTests/CaretChordTests.swift diff --git a/KanbanTests/CaretChordTests.swift b/KanbanTests/CaretChordTests.swift new file mode 100644 index 0000000..9e3175d --- /dev/null +++ b/KanbanTests/CaretChordTests.swift @@ -0,0 +1,103 @@ +import Testing +@testable import Kanban + +/// `caretChordsYield(boardInfo:search:)` — 04-interactions.md ▸ Grammar's caret-chords rule as one +/// expression, and `BoardCommands.swift`'s single seam for it: Board ▸ Move Left/Move Right ⌘←/⌘→ and +/// the lane-width pair ⌥⌘←/⌥⌘→ disable via menu validation whenever *any* text control has keyboard +/// focus, because ⌘←/⌘→ are the standard line-start/end caret chords and an enabled key equivalent +/// fires before a field ever sees the key. +/// +/// The function reads exactly two flags and nothing else, so every test here constructs +/// `BoardInfoPresentation` and `BoardSearchPresentation` directly rather than through a `BoardStore` — +/// a fixture that stood up a board would be exercising machinery this seam never touches. +@MainActor +@Suite("caretChordsYield ▸ the caret-chords rule") +struct CaretChordTests { + + // MARK: Scopeless windows + + @Test("A card window publishes neither presentation, and the chords stay enabled") + func scopelessWindowDoesNotYield() { + // "Card-window fields need nothing: those windows never publish a boardStore, so both items + // are already scopeless there" (caretChordsYield's doc comment) — nil/nil is that window's + // steady state, not a corner case. + #expect(caretChordsYield(boardInfo: nil, search: nil) == false) + } + + // MARK: The board popover, alone + + @Test("The board popover open yields; closed, it does not") + func popoverPresence() { + let boardInfo = BoardInfoPresentation() + #expect(caretChordsYield(boardInfo: boardInfo, search: nil) == false, "closed by default") + + boardInfo.isPresented = true + #expect(caretChordsYield(boardInfo: boardInfo, search: nil) == true) + + boardInfo.isPresented = false + #expect(caretChordsYield(boardInfo: boardInfo, search: nil) == false, "closing re-enables the chords") + } + + // MARK: The search field, alone + + @Test("The search field focused yields; unfocused, it does not") + func searchFocus() { + let search = BoardSearchPresentation() + #expect(caretChordsYield(boardInfo: nil, search: search) == false, "unfocused by default") + + search.isFocused = true + #expect(caretChordsYield(boardInfo: nil, search: search) == true) + + search.isFocused = false + #expect(caretChordsYield(boardInfo: nil, search: search) == false, "losing focus re-enables the chords") + } + + // MARK: Both surfaces together + + @Test("Either surface active is enough to yield; both absent-or-inert is the only false") + func eitherSurfaceIsSufficient() { + let closedInfo = BoardInfoPresentation() + let openInfo = BoardInfoPresentation() + openInfo.isPresented = true + let unfocusedSearch = BoardSearchPresentation() + let focusedSearch = BoardSearchPresentation() + focusedSearch.isFocused = true + + // One side published and inert, the other absent (the still-loading-window shape): false. + #expect(caretChordsYield(boardInfo: closedInfo, search: nil) == false) + #expect(caretChordsYield(boardInfo: nil, search: unfocusedSearch) == false) + + // One side published and active, the other absent: true. + #expect(caretChordsYield(boardInfo: openInfo, search: nil) == true) + #expect(caretChordsYield(boardInfo: nil, search: focusedSearch) == true) + + // Both published, both inert: false — the popover being open at all and the field holding + // focus are each read independently, so neither's mere presence counts on its own. + #expect(caretChordsYield(boardInfo: closedInfo, search: unfocusedSearch) == false) + + // Both published, one or both active: true. This is an `||`, not an `&&` — one live text + // surface is enough to send the chords to it, whatever the other surface is doing. + #expect(caretChordsYield(boardInfo: openInfo, search: unfocusedSearch) == true) + #expect(caretChordsYield(boardInfo: closedInfo, search: focusedSearch) == true) + #expect(caretChordsYield(boardInfo: openInfo, search: focusedSearch) == true) + } + + // MARK: Inline editors are a different seam + + @Test("An inline title editor is not this seam's job — acceptsBoardMutations covers it") + func inlineEditingIsAForeignConcern() { + // Inline title editors are the caret-chords rule's *first* named surface (the doc comment's + // list), but they are answered through `acceptsBoardMutations`, not here: `MoveLaneCommands` + // and `LaneWidthCommands` each disable on `yieldsCaretChords || `, so a rename or a new-card placeholder already closes the items through that other + // half of the `||` before this function is ever asked. Pinning `caretChordsYield`'s own + // independence from `isEditingInline` is what keeps that split honest. + let transient = TransientBoardState() + transient.beginRename(of: ItemID(rawValue: "11111111-1111-4111-8111-111111111111"), currentTitle: "Card") + #expect(transient.isEditingInline == true, "the rename editor is open") + + // No board popover, no search focus — caretChordsYield answers false regardless of the open + // editor above, because it never reads isEditingInline at all. + #expect(caretChordsYield(boardInfo: nil, search: nil) == false) + } +}