A card whose thread holds one comment or more now draws a second trailing chip beside the paperclip: a secondary-tinted bubble glyph plus its count, shown only when the count is above zero (design ruling 2026-08-09, card e729e30a). Same styling family as the attachments chip — caption size, secondary tint, decorative and hidden outright from the accessibility tree — but this one carries a visible count rather than staying icon-only, per the ruling's own "bubble-style SF Symbol + count." It sits after the attachments chip at the row's trailing edge, in both the live title row and the drag replica. The count is a new `Card.commentCount` field the loader fills with a readdir over `comments/`'s identity-shaped children that carry their own `index.md` — `BoardLoader.commentCount(in:)`, built on the same `identityShapedChildren` predicate a trash entry's held-card count already uses. Never a parse: `.draft` and `.trash/` are excluded for free, the same dot-prefixed hidden-entry skip `CommentThread.load` documents for both, so the walk stays exactly the O(cards) shape 01-storage-format.md § Enhanced schema already commits to. Because the count rides inside the `card: Card` parameter `CardFaceView` already takes — not a new parameter of its own — drawing the chip costs nothing beyond a field read on an already-compared value: no new Observable read joins the body, and the equatable gate already covers it via `Card`'s synthesized `Equatable`. The one divergence from the comments pane's parsed count is documented rather than hidden: a comment folder whose `index.md` exists but fails to parse is a `Stray` the thread read excludes by opening and rejecting it, a cost this readdir does not pay. The face may then read one comment high until that folder is fixed or removed — the trade the ruling's "cheap directory-entry count… not a parse" asks for, over paying full parse cost on every card of every load. Every well-formed comment, and every card with no malformed one, agrees with the pane exactly. VoiceOver: `AccessibilityPhrases.cardValue` gains a `comments: Int` parameter, appended after attachments and before the cut-pending phrase — the same left-to-right order the two chips draw in, so a sighted read and a VoiceOver read never disagree about which comes first. The trashed lane row's own call site (an opaque unit with no comments to speak of) passes `comments: 0`. Docs: DESIGN/03-board-ui.md's card-face section describes both chips and retires the stale "closed with no growth" sentence, honestly recording the 2026-08-09 growth (the hero banner landed hours earlier, this chip after it) as exposure of facts the card already carries rather than a body excerpt. DESIGN/10-accessibility.md's flattened-element sentence gains the comment count. DESIGN/01-storage-format.md's Enhanced schema paragraph records the chip as shipped. WISHLIST #9 is marked shipped in place — not renumbered, since #10 and #11 are cross-referenced elsewhere. Tests: CardCommentCountListingTests (BoardLoaderTests.swift) pins the readdir against a synthetic tree — no comments/ folder, an empty one, non-identity-shaped and index-less strays excluded, .draft/.trash/ excluded for free, agreement with CommentThread.load's parsed count in the well-formed case, and the one documented divergence on a malformed index.md. AccessibilityPhrasesTests covers cardValue's new parameter alone, alongside attachments, and all three fragments together. ViewEquatableTests pins that a comment landing on a card is a gate difference. BoardRenderPerformanceTests adds a render-cost guard: one comment added to one card on a hosted 180-card board re-renders a handful of bodies, not the board. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
362 lines
15 KiB
Swift
362 lines
15 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 collapse chevron is a labeled child ("Collapse ⟨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: - 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, comments: 0, isCutPending: false).isEmpty)
|
|
}
|
|
|
|
@Test("Attachments ride the value, plural-folded")
|
|
func cardValueAttachments() {
|
|
#expect(
|
|
AccessibilityPhrases.cardValue(attachments: 1, comments: 0, isCutPending: false)
|
|
== "1 attachment"
|
|
)
|
|
#expect(
|
|
AccessibilityPhrases.cardValue(attachments: 4, comments: 0, isCutPending: false)
|
|
== "4 attachments"
|
|
)
|
|
}
|
|
|
|
/// Comments ride the value too (design ruling 2026-08-09, card e729e30a) — the same plural
|
|
/// folding the comments pane's own header uses (`commentCount`), so a face value and the pane
|
|
/// can never disagree about how "1 comment" reads.
|
|
@Test("Comments ride the value, plural-folded")
|
|
func cardValueComments() {
|
|
#expect(
|
|
AccessibilityPhrases.cardValue(attachments: 0, comments: 1, isCutPending: false)
|
|
== "1 comment"
|
|
)
|
|
#expect(
|
|
AccessibilityPhrases.cardValue(attachments: 0, comments: 3, isCutPending: false)
|
|
== "3 comments"
|
|
)
|
|
}
|
|
|
|
@Test("A cut-pending card says so")
|
|
func cardValueCutPending() {
|
|
#expect(
|
|
AccessibilityPhrases.cardValue(attachments: 0, comments: 0, isCutPending: true)
|
|
== "cut, pending paste"
|
|
)
|
|
}
|
|
|
|
/// Attachments and comments together, attachments first — the chips' own left-to-right order on
|
|
/// the face (`CardFaceView.titleRow`).
|
|
@Test("Attachments and comments both ride the value, attachments first")
|
|
func cardValueAttachmentsAndComments() {
|
|
#expect(
|
|
AccessibilityPhrases.cardValue(attachments: 2, comments: 3, isCutPending: false)
|
|
== "2 attachments, 3 comments"
|
|
)
|
|
}
|
|
|
|
/// All three fragments in one value, in the fixed order: attachments, then comments, then the
|
|
/// cut-pending phrase last, since it describes what is about to happen rather than a fact about
|
|
/// the card's own content.
|
|
@Test("A cut card with files and comments carries all three fragments")
|
|
func cardValueAllThree() {
|
|
#expect(
|
|
AccessibilityPhrases.cardValue(attachments: 2, comments: 1, isCutPending: true)
|
|
== "2 attachments, 1 comment, 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")
|
|
}
|
|
|
|
// MARK: - The comments pane
|
|
|
|
/// 10-accessibility.md ▸ Comments: "the pane is a labeled container ('Comments, N')".
|
|
@Test("The pane's container names itself and its count")
|
|
func commentsContainer() {
|
|
#expect(AccessibilityPhrases.commentsContainerLabel(count: 3) == "Comments, 3")
|
|
#expect(
|
|
AccessibilityPhrases.commentsContainerLabel(count: 0) == "Comments, 0",
|
|
"a comment-less card still shows the pane — the invitation is the point"
|
|
)
|
|
}
|
|
|
|
@Test("A comment count folds its plural, like every other count in the app")
|
|
func commentCounts() {
|
|
#expect(AccessibilityPhrases.commentCount(1) == "1 comment")
|
|
#expect(AccessibilityPhrases.commentCount(4) == "4 comments")
|
|
}
|
|
|
|
/// The flattened element: the author line is what it *is*, the body is what it holds.
|
|
@Test("A comment's label is its author line, and its value is its body")
|
|
func commentElement() {
|
|
#expect(AccessibilityPhrases.commentLabel(authorLine: "Ada Lovelace · 1 Jan 2026") == "Ada Lovelace · 1 Jan 2026")
|
|
#expect(AccessibilityPhrases.commentValue(body: "A remark.\n", attachments: 0) == "A remark.")
|
|
#expect(
|
|
AccessibilityPhrases.commentValue(body: "A remark.\n", attachments: 2) == "A remark., 2 attachments"
|
|
)
|
|
}
|
|
|
|
@Test("A comment with nothing to attribute is still a named element")
|
|
func unattributedComment() {
|
|
// "Unattributed" is the absence of a name, never a name to speak (`CommentAuthorLine`) — but an
|
|
// unlabeled element is an audit failure, so the fallback is the noun itself.
|
|
#expect(AccessibilityPhrases.commentLabel(authorLine: nil) == "Comment")
|
|
#expect(AccessibilityPhrases.commentValue(body: " \n", attachments: 0) == "")
|
|
}
|
|
|
|
/// The custom actions must be the *same* strings as the context menu's rows (10 ▸ Comments), so a
|
|
/// user who has learned the pointer inventory hears the same three words from the rotor.
|
|
@Test("The comment's custom actions are its context menu's rows")
|
|
func commentActions() {
|
|
#expect(AccessibilityPhrases.commentEditAction == "Edit")
|
|
#expect(AccessibilityPhrases.commentDeleteAction == "Delete")
|
|
#expect(AccessibilityPhrases.commentRevealAction == "Reveal in Finder")
|
|
}
|
|
|
|
@Test("The composer, the sort control and the paperclip are labeled")
|
|
func commentControls() {
|
|
#expect(AccessibilityPhrases.commentComposerLabel == "Add a comment")
|
|
#expect(AccessibilityPhrases.commentSortLabel == "Sort")
|
|
#expect(AccessibilityPhrases.commentAttachFilesLabel == "Attach Files")
|
|
}
|
|
}
|
|
|
|
/// 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")
|
|
}
|