Pin the caret-chord yielding seam with tests
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
This commit is contained in:
@@ -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 || <the acceptsBoardMutations
|
||||||
|
// check>`, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user