Files
lanework/KanbanTests/AccessibilityPhrasesTests.swift
T
rzen 273c182ef4 Build the VoiceOver tree and actions
The board window's accessibility tree per DESIGN/10: lanes are containers
labeled "<title>, lane, N cards" (filter-aware count = renderedCards, the
badge's own collection); cards are one flattened element each — label =
title or the untitled placeholder, value = attachment count + "cut,
pending paste", selection via trait; face icon, stripe, and paperclip are
decorative and hidden. Masonry never leaks into traversal: slots carry
order-keyed accessibilitySortPriority, so a wide lane reads by card order,
not column-major. Lane titles carry the heading trait for the rotor.

VO-Space is the ⌘-click analogue routed through the existing
BoardStore.click funnel (SelectionGrammar stays the single answer for
toggle and container-boundary rules) — cards and lane headers both.
Context-menu rows double as custom accessibility actions, each calling
the same private method as its menu row so the surfaces cannot drift;
trash cards expose Delete and Reveal in Finder and never Open. The trash
column is pinned last via sort priority 0, its label/value re-routed
through the new AccessibilityPhrases seam; toggling trash visibility
posts a one-line announcement from the store seam (both command faces).
The invisible lane-resize drag strip leaves the tree — the stepper and
menu items are the accessible width path.

AccessibilityPhrases is the pure vocabulary seam (labels, values, plural
folding shared with TrashModel.phrase), pinned by its own test suite.
Both schemes build; 1466 unit tests green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 07:38:38 -04:00

138 lines
5.6 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// The board's spoken vocabulary — 10-accessibility.md ▸ The board through VoiceOver, which states
/// the tree as sentences:
///
/// > A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like
/// > the visible badge. The lane header's new-card button is a labeled child ("New card in ⟨lane⟩").
/// > A card is one flattened element: label = title (or the untitled placeholder), value carries the
/// > attachment count when present, selected state via trait … cut cards expose their dimmed pending
/// > state in the value ("cut, pending paste").
///
/// Every one of those is a rule about *text*, and `AccessibilityPhrases` is where they are decided,
/// so this is where they can be held to account without a window or a screen reader.
@Suite("AccessibilityPhrases")
struct AccessibilityPhrasesTests {
// MARK: - The untitled placeholder
@Test("A titled item speaks its title")
func titledItem() {
#expect(AccessibilityPhrases.displayTitle("Fix login") == "Fix login")
}
@Test("An untitled item speaks the same placeholder the face draws")
func untitledItem() {
#expect(AccessibilityPhrases.displayTitle(nil) == "Untitled")
}
/// A committed-empty rename removes the `title` key, but a hand-written `title: ""` is a value
/// the parser keeps — and an element labeled with the empty string is an element with no name.
@Test("An empty title reads as the placeholder, not as nothing")
func emptyTitle() {
#expect(AccessibilityPhrases.displayTitle("") == "Untitled")
}
// MARK: - Lane containers
@Test("A lane container is ⟨title⟩, lane, N cards")
func laneLabel() {
#expect(AccessibilityPhrases.laneLabel(title: "Doing", cards: 3) == "Doing, lane, 3 cards")
}
@Test("The lane's card count folds its plural")
func laneLabelSingular() {
#expect(AccessibilityPhrases.laneLabel(title: "Doing", cards: 1) == "Doing, lane, 1 card")
}
/// "Lanes are never filtered out … a lane the query empties shows 0 and keeps its slot"
/// (04-interactions.md § Search) — so zero is a real, speakable state, not an absence.
@Test("An emptied lane says zero rather than going quiet")
func laneLabelEmpty() {
#expect(AccessibilityPhrases.laneLabel(title: "Done", cards: 0) == "Done, lane, 0 cards")
}
@Test("An untitled lane still reads as a lane with a count")
func laneLabelUntitled() {
#expect(AccessibilityPhrases.laneLabel(title: nil, cards: 2) == "Untitled, lane, 2 cards")
}
/// The lane's spoken count and the trash's are one function, so they can never fold a plural
/// two different ways.
@Test("A lane's count phrase and the trash's are the same phrase")
func countsShareOneFolding() {
for count in [0, 1, 2, 41] {
#expect(AccessibilityPhrases.cardCount(count) == TrashModel.phrase(count))
}
}
// MARK: - The new-card button
@Test("The header button names its lane")
func newCardLabel() {
#expect(AccessibilityPhrases.newCardLabel(lane: "Doing") == "New card in Doing")
}
@Test("The header button of an untitled lane names the placeholder")
func newCardLabelUntitled() {
#expect(AccessibilityPhrases.newCardLabel(lane: nil) == "New card in Untitled")
}
// MARK: - Card elements
@Test("A card's label is its title")
func cardLabel() {
#expect(AccessibilityPhrases.cardLabel(title: "Fix login") == "Fix login")
#expect(AccessibilityPhrases.cardLabel(title: nil) == "Untitled")
}
/// "Value carries the attachment count **when present**" — an ordinary card has no value at all,
/// and an empty AXValue speaks as nothing.
@Test("A plain card carries no value")
func cardValueEmpty() {
#expect(AccessibilityPhrases.cardValue(attachments: 0, isCutPending: false).isEmpty)
}
@Test("Attachments ride the value, plural-folded")
func cardValueAttachments() {
#expect(AccessibilityPhrases.cardValue(attachments: 1, isCutPending: false) == "1 attachment")
#expect(AccessibilityPhrases.cardValue(attachments: 4, isCutPending: false) == "4 attachments")
}
@Test("A cut-pending card says so")
func cardValueCutPending() {
#expect(AccessibilityPhrases.cardValue(attachments: 0, isCutPending: true) == "cut, pending paste")
}
/// Both fragments in one value, attachments first: the count is a fact about the card, the cut
/// is a fact about what is about to happen to it.
@Test("A cut card with files carries both fragments")
func cardValueBoth() {
#expect(
AccessibilityPhrases.cardValue(attachments: 2, isCutPending: true)
== "2 attachments, cut, pending paste"
)
}
// MARK: - The trash column
@Test("The trash label is stable and its value is its count")
func trashContainer() {
#expect(AccessibilityPhrases.trashLabel == "Trash")
#expect(AccessibilityPhrases.trashValue(cards: 41) == "41 cards")
#expect(AccessibilityPhrases.trashValue(cards: 1) == "1 card")
#expect(AccessibilityPhrases.trashValue(cards: 0) == "0 cards")
}
/// The announcement states the resulting state rather than the action, so a user who mis-hit the
/// toggle learns where the board ended up.
@Test("Toggling trash visibility announces the resulting state")
func trashVisibility() {
#expect(AccessibilityPhrases.trashVisibility(shown: true) == "Trash shown")
#expect(AccessibilityPhrases.trashVisibility(shown: false) == "Trash hidden")
}
}