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") } }