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