Files
lanework/KanbanTests/AccessibilityPhrasesTests.swift
T
rzen f7c8088783 Lanes delete into the trash — rendering, grammar, drag, clipboard, a11y
Phase 2 completes the lanes-in-trash card. TrashEntry merges the
trash's two kinds by rank in exactly ONE place (ItemPath.resolve's
own merge deleted in favor of it — the three-merge-points finding
shrinks instead of growing). TrashLaneRowView renders the opaque
row — tertiary plate, level-default lane glyph never the lane's own
icon, title + card count, no accents, no expansion; the column badge
counts rendered rows. Selection grammar: kind-homogeneous trash
selections — ranges skip the other kind, ⇧-extension stops at the
kind boundary, plain arrows walk the merged order, marquee stays
card-only (now load-bearing: rows register frames for arrows),
Select All card-scoped; successor-on-purge crosses kinds like
navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop
accepts lane sessions (drop on shown trash deletes), restoreLanes
routes a trash-sourced strip drop as an arrival-ranked within-board
move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque
lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as
the root — a same-board restore looked like an import and would
have reminted the lane it was restoring (pinned by test). A11y:
row = one flattened "title, deleted lane, N cards" element with
Delete/Reveal actions; BoardDiff crossings read lanes as
deleted/restored, shown-trash churn digested at row level. Agent
guide stays v7 — the literal already teaches lanes-trash-by-move
and kind stamping; drift-guard pins those lines. README trash
paragraph notes lanes.

Both schemes 1893 tests / 322 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 17:04:30 -04:00

287 lines
12 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")
}
/// With lane rows in the column the value names both kinds — the rows are what the VoiceOver
/// cursor is about to walk into, and a card-only count would understate the container.
@Test("The trash's value names its lane rows when it has any")
func trashValueCountsRows() {
#expect(AccessibilityPhrases.trashValue(cards: 3, lanes: 1) == "3 cards, 1 lane")
#expect(AccessibilityPhrases.trashValue(cards: 0, lanes: 2) == "0 cards, 2 lanes")
// A trash with no rows of the other kind reads exactly as it always did.
#expect(AccessibilityPhrases.trashValue(cards: 3, lanes: 0) == "3 cards")
}
/// "A trashed **lane** is one flattened opaque element — '⟨title⟩, deleted lane, N cards' — never
/// a container" (10-accessibility.md ▸ Trash lane), the design's own phrase.
@Test("A trashed lane row says what it is and what it is holding")
func trashedLaneRow() {
#expect(AccessibilityPhrases.trashedLaneLabel(title: "Doing", cards: 5) == "Doing, deleted lane, 5 cards")
#expect(AccessibilityPhrases.trashedLaneLabel(title: "Doing", cards: 1) == "Doing, deleted lane, 1 card")
#expect(AccessibilityPhrases.trashedLaneLabel(title: nil, cards: 0) == "Untitled, deleted lane, 0 cards")
// The untitled placeholder is the one the face draws, not a second spelling.
#expect(AccessibilityPhrases.trashedLaneLabel(title: "", cards: 0)
== AccessibilityPhrases.trashedLaneLabel(title: nil, cards: 0))
}
/// 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")
}
// MARK: - The live board digest
/// 10-accessibility.md's own example sentence, reproduced exactly — which is what fixes the
/// category order's first pair (edited before added).
@Test("The digest is the design's own sentence")
func digestExample() {
var diff = BoardDiff()
diff.cards.edited = [id(1), id(2)]
diff.cards.added = [id(3)]
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 2 cards edited, 1 card added")
}
@Test("Zero categories are omitted, never spoken as 'and 0 cards moved'")
func digestOmitsEmptyCategories() {
var diff = BoardDiff()
diff.cards.deleted = [id(1)]
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 1 card deleted")
}
@Test("Every count folds its plural")
func digestFoldsPlurals() {
var diff = BoardDiff()
diff.cards.moved = [id(1)]
diff.lanes.added = [id(2), id(3)]
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 1 card moved, 2 lanes added")
}
/// Cards before lanes, and within a kind: edited, added, moved, deleted.
@Test("The category order is fixed, cards before lanes")
func digestOrdering() {
var diff = BoardDiff()
diff.cards.edited = [id(1)]
diff.cards.added = [id(2)]
diff.cards.moved = [id(3)]
diff.cards.deleted = [id(4)]
diff.lanes.edited = [id(5)]
diff.lanes.added = [id(6)]
diff.lanes.moved = [id(7)]
diff.lanes.deleted = [id(8)]
#expect(
AccessibilityPhrases.boardChanged(diff)
== "Board changed: 1 card edited, 1 card added, 1 card moved, 1 card deleted, "
+ "1 lane edited, 1 lane added, 1 lane moved, 1 lane deleted"
)
}
/// "Silence about a mutating board is a lie" — a change no bucket counts still says something.
@Test("An uncounted change is the bare sentence")
func digestBareSentence() {
var diff = BoardDiff()
diff.boardChanged = true
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed")
}
@Test("A board that did not change says nothing at all")
func digestSilence() {
#expect(AccessibilityPhrases.boardChanged(BoardDiff()) == nil)
}
// MARK: - A vanishing focus
@Test("A vanished card is named — the design's own sentence")
func vanishedCard() {
#expect(
AccessibilityPhrases.vanishedFocus(.card(title: "Fix login"))
== "Card 'Fix login' was deleted externally"
)
}
@Test("A vanished lane names itself and what went with it")
func vanishedLane() {
#expect(
AccessibilityPhrases.vanishedFocus(.lane(title: "Doing", cards: 5))
== "Lane 'Doing' was deleted externally, with 5 cards"
)
#expect(
AccessibilityPhrases.vanishedFocus(.lane(title: "Doing", cards: 1))
== "Lane 'Doing' was deleted externally, with 1 card"
)
}
/// "With 0 cards" would be an odd way to say "and nothing else went with it".
@Test("An empty lane vanishing has no count clause")
func vanishedEmptyLane() {
#expect(
AccessibilityPhrases.vanishedFocus(.lane(title: "Done", cards: 0))
== "Lane 'Done' was deleted externally"
)
}
@Test("An untitled item still gets a name in the sentence")
func vanishedUntitled() {
#expect(
AccessibilityPhrases.vanishedFocus(.card(title: nil))
== "Card 'Untitled' was deleted externally"
)
}
// MARK: - The banner strip
/// The tone must reach a VoiceOver user as a *word*: colour cannot carry it, and the row's label
/// and its announcement are the same string by construction.
@Test("A banner row speaks its tone before its headline")
func bannerLabel() {
#expect(AccessibilityPhrases.bannerLabel(tone: .error, headline: "Couldn't move 'Fix login'")
== "Error: Couldn't move 'Fix login'")
#expect(AccessibilityPhrases.bannerTonePrefix(.warning) == "Warning")
#expect(AccessibilityPhrases.bannerTonePrefix(.info) == "Status", "the platform's word for a calm state")
}
@Test("A healed condition states the regained capability, not the vanished cause")
func clearedConditions() {
#expect(AccessibilityPhrases.readOnlyLockCleared == "The board is editable again")
#expect(AccessibilityPhrases.reloadBreakageCleared == "The board is loading again")
}
}
/// Distinct identities for the digest cases, which care only about *counts* — the diff's own suite
/// is where identity is at stake.
private func id(_ n: Int) -> ItemID {
ItemID(rawValue: "0000000\(n)-0000-4000-8000-000000000000")
}