The board speaks when files change under the user, per DESIGN/10 § Live board announcements. BoardDiff is the pure snapshot summarizer (identity sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so pro-m1's semantic commit engine can build on it; edited = rendered content only, moved beats edited, implied events don't steal the subject). BoardAnnouncer is the decision seam: focusOutcome computes the vanishing-focus sentence and the walk-up-then-sideways recovery (next lane by order, else previous, board container only when none remain, never the trash); speech(for:) is the one-sentence precedence ladder — raised condition > bracket completion > cleared condition > vanished focus > digest — foreign-only for the last two rungs, so app-mediated echoes stay silent. BoardStore.land assembles ReloadFacts and posts exactly one sentence per reload through the injectable announce outlet (AccessibilityAnnouncer, medium priority, never interrupting). Selection recovery layers on top of ItemReferenceSet re-resolution — survivors veto, the emptied selection lands on the vanished item's lane and re-arms ⌘N's active-lane memory. performWholesale(announcing:) arms a completion phrase consumed by the closing reload — nil on every base bracket today; pro-m1 fills git phrasings. Locks raised outside the reload path (vanished root, unwritable location) announce through the same ladder, and the banner strip is a labeled "Board status" container whose row labels are the announced sentences (AccessibilityPhrases.bannerLabel — one string for eye and ear). Announcements classify at reload granularity (WatchOrigin) as a deliberate interim: DESIGN/02's EchoLedger (per-file classification, the announcer's specified input, git-free) was scheduled with the auto-committer that the edition split moved to pro-m1 — filed on the Redesign board for a ruling. 1533 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
265 lines
10 KiB
Swift
265 lines
10 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")
|
|
}
|
|
|
|
// 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")
|
|
}
|