Implement live accessibility announcements
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
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - BoardAnnouncer
|
||||
|
||||
/// **What a landing reload says out loud, and where focus goes when it lands on a hole** —
|
||||
/// 10-accessibility.md ▸ Live board announcements, as pure functions of the two snapshots, the
|
||||
/// reload's origin, and the standing banner conditions.
|
||||
///
|
||||
/// ### Why the whole decision is a value function
|
||||
///
|
||||
/// The rules 10 states are all *decisions*, not effects: which origins speak ("foreign changes
|
||||
/// announce, app-mediated echoes never do"), what one reload is allowed to say ("one polite digest
|
||||
/// per reload debounce … never per-file chatter"), which sentence wins when two apply, and where a
|
||||
/// vanished focus recovers to ("walks up then sideways"). Every one of them is checkable without a
|
||||
/// window, a screen reader, or a running app — so every one of them lives here, and the store keeps
|
||||
/// only the two thin acts a pure function cannot perform: posting the notification and moving the
|
||||
/// selection. `Motion` is the same shape for the same reason, on the same seam.
|
||||
///
|
||||
/// ### The ladder, and why speech is rationed
|
||||
///
|
||||
/// "Exactly one announcement per reload" is the whole point of the debounce — a board being
|
||||
/// rewritten by an agent must not turn VoiceOver into a ticker — so `speech(for:)` returns **one
|
||||
/// optional sentence**, chosen by precedence rather than concatenated:
|
||||
///
|
||||
/// 1. **A standing condition that just appeared** — the read-only lock or reload breakage. 10 makes
|
||||
/// the live-reload-resilience banner an announced element in its own right, and a board that has
|
||||
/// just stopped being writable outranks any description of what changed in it.
|
||||
/// 2. **A bracketed operation's completion phrase** — "announce once, at completion, never their
|
||||
/// internal churn". A bracket's reload is the operation's result; the churn inside it is not the
|
||||
/// user's business and its digest would describe a tree they never saw.
|
||||
/// 3. **A standing condition that just cleared** — the other half of "announced when it appears and
|
||||
/// when it clears". Below completion because a completion phrase already implies the board is
|
||||
/// reading and writing again.
|
||||
/// 4. **A vanished focus** — the specific sentence, which beats the generic one (below).
|
||||
/// 5. **The board digest** — the ordinary foreign-change case.
|
||||
///
|
||||
/// Rungs 4 and 5 are foreign-only; 1 through 3 are not, because a lock raised by the app's own
|
||||
/// bracketed operation is exactly the case 10 names ("including the read-only lock after a failed
|
||||
/// bracketed reload"), and silence there would be the app hiding its own failure.
|
||||
public enum BoardAnnouncer {
|
||||
|
||||
// MARK: - A vanishing focus
|
||||
|
||||
/// What disappeared under the focus, as the announcement's *subject*.
|
||||
///
|
||||
/// Two cases and not three, because 10 settles the composition: when the lane itself vanished
|
||||
/// the announcement names the **lane**, not the card — "the implied-events-don't-steal-the-
|
||||
/// subject discipline of 06-history-undo.md's composer, applied to speech". So a focused card
|
||||
/// whose lane was deleted produces `.lane`, and `.card` is reserved for a card that vanished out
|
||||
/// of a lane that is still there.
|
||||
public enum VanishedFocus: Sendable, Equatable {
|
||||
case card(title: String?)
|
||||
case lane(title: String?, cards: Int)
|
||||
}
|
||||
|
||||
/// Where focus lands after the item under it stopped existing.
|
||||
///
|
||||
/// **Never the trash.** 10 is explicit — "never into the trash, which stays hidden — no layout
|
||||
/// side effects from a foreign edit" — so the two cases are the two board-side destinations and
|
||||
/// there is no third to spell.
|
||||
public enum FocusRecovery: Sendable, Equatable {
|
||||
/// Select this lane: the focused card's own lane when it survived, otherwise the lane now
|
||||
/// occupying the vanished lane's position.
|
||||
case lane(ItemID)
|
||||
/// Select nothing — the board container itself, "only when no lanes remain".
|
||||
case boardContainer
|
||||
}
|
||||
|
||||
/// A reload's effect on focus: what to say about it, and where to put it.
|
||||
///
|
||||
/// Both halves or neither, always: the sentence names what vanished and the recovery is where
|
||||
/// the user is left, and a design that produced one without the other would either move focus
|
||||
/// silently or describe a move that did not happen.
|
||||
public struct FocusOutcome: Sendable, Equatable {
|
||||
public var vanished: VanishedFocus?
|
||||
public var recovery: FocusRecovery?
|
||||
|
||||
public init(vanished: VanishedFocus? = nil, recovery: FocusRecovery? = nil) {
|
||||
self.vanished = vanished
|
||||
self.recovery = recovery
|
||||
}
|
||||
|
||||
/// Nothing happened to focus — the overwhelmingly common outcome, and the one every guard
|
||||
/// below falls out to.
|
||||
public static let survived = FocusOutcome()
|
||||
}
|
||||
|
||||
/// Whether this reload pulled the ground out from under the focused item, and where focus goes
|
||||
/// if it did.
|
||||
///
|
||||
/// `focused` is the *cursor*, not the set: `TransientBoardState.selectionHead` when it is still
|
||||
/// in the selection, else a sole selected item (`BoardStore.focusedItem`). 10 speaks of "the
|
||||
/// selected or VO-focused card" in the singular, and a sentence naming one card out of five is a
|
||||
/// worse answer than the digest.
|
||||
///
|
||||
/// ### Three guards, each of them a rule
|
||||
///
|
||||
/// - **The board container only.** A trash selection is not on the board, 10 keeps focus out of
|
||||
/// the trash on principle, and there is no lane to recover to from in there.
|
||||
/// - **The focus must actually be gone.** A reload that reordered the board around a surviving
|
||||
/// card has nothing to announce and nothing to recover.
|
||||
/// - **Survivors veto the recovery.** If any other selected item is still there, the selection
|
||||
/// already sits somewhere the user chose; moving it to a lane would be the reload editing a
|
||||
/// live selection, and 02-architecture.md's re-resolution rule ("vanished members leave
|
||||
/// silently, no substitute is invented") stands untouched for that case. Recovery is the
|
||||
/// *emptied* selection's answer, which is exactly when nothing else can be.
|
||||
///
|
||||
/// The last guard is why this can be layered on `ItemReferenceSet.resolved(against:)` rather
|
||||
/// than replacing it: the set rule still runs first and still invents nothing; this decides what
|
||||
/// to do about the hole it leaves, which is 10's question and not the set's.
|
||||
public static func focusOutcome(
|
||||
old: BoardModel,
|
||||
new: BoardModel,
|
||||
selection: ItemReferenceSet,
|
||||
focused: ItemID?
|
||||
) -> FocusOutcome {
|
||||
guard selection.container == .board,
|
||||
let focused,
|
||||
selection.ids.contains(focused)
|
||||
else { return .survived }
|
||||
|
||||
let universe = ItemContainer.board.ids(in: new)
|
||||
guard !universe.contains(focused) else { return .survived }
|
||||
guard selection.ids.isDisjoint(with: universe) else { return .survived }
|
||||
|
||||
// A focused *lane* that vanished is already the lane case — no card to be displaced by.
|
||||
if let lane = old.lanes.first(where: { $0.id == focused }) {
|
||||
return FocusOutcome(
|
||||
vanished: .lane(title: lane.title.value, cards: lane.cards.count),
|
||||
recovery: successorLane(of: lane.id, old: old, new: new)
|
||||
)
|
||||
}
|
||||
|
||||
guard let home = old.lanes.first(where: { lane in lane.cards.contains { $0.id == focused } }),
|
||||
let card = home.cards.first(where: { $0.id == focused })
|
||||
else {
|
||||
// The focus was not on the board's old side either — a stale reference no reload can
|
||||
// describe. Silence and no movement is the honest answer.
|
||||
return .survived
|
||||
}
|
||||
|
||||
if new.lanes.contains(where: { $0.id == home.id }) {
|
||||
return FocusOutcome(vanished: .card(title: card.title.value), recovery: .lane(home.id))
|
||||
}
|
||||
return FocusOutcome(
|
||||
vanished: .lane(title: home.title.value, cards: home.cards.count),
|
||||
recovery: successorLane(of: home.id, old: old, new: new)
|
||||
)
|
||||
}
|
||||
|
||||
/// **Walk up, then sideways** (10, settled): the lane now occupying the vanished lane's
|
||||
/// position — the next lane by `order`, else the previous one — and the board container only
|
||||
/// when no lanes remain.
|
||||
///
|
||||
/// This is 04-interactions.md's ⌫-successor pattern applied to external change, and it is
|
||||
/// computed against the **old** lane list because that is the only place the vanished lane still
|
||||
/// has a position. `BoardModel.lanes` is already in display order (`Ranks.sortedForDisplay`), so
|
||||
/// "next by `order`" is the next surviving element and needs no sort of its own.
|
||||
///
|
||||
/// The final fallback is `new.lanes.first` rather than the board container: 10 reserves the
|
||||
/// container for "when no lanes remain", so a reload that replaced every lane with different
|
||||
/// ones — a checkout, a template applied by an agent — lands on the leftmost of what is actually
|
||||
/// there rather than nowhere.
|
||||
static func successorLane(of vanished: ItemID, old: BoardModel, new: BoardModel) -> FocusRecovery {
|
||||
let surviving = Set(new.lanes.map(\.id))
|
||||
guard let position = old.lanes.firstIndex(where: { $0.id == vanished }) else {
|
||||
return new.lanes.first.map { .lane($0.id) } ?? .boardContainer
|
||||
}
|
||||
for lane in old.lanes[old.lanes.index(after: position)...] where surviving.contains(lane.id) {
|
||||
return .lane(lane.id)
|
||||
}
|
||||
for lane in old.lanes[..<position].reversed() where surviving.contains(lane.id) {
|
||||
return .lane(lane.id)
|
||||
}
|
||||
return new.lanes.first.map { .lane($0.id) } ?? .boardContainer
|
||||
}
|
||||
|
||||
// MARK: - The reload's one sentence
|
||||
|
||||
/// Everything one landed reload knows that could bear on what it says — gathered into a value so
|
||||
/// the decision below reads as a ladder rather than as a nine-argument call, and so a test can
|
||||
/// state one fact and default the rest.
|
||||
public struct ReloadFacts: Sendable, Equatable {
|
||||
|
||||
/// The reload's provenance — the classification 10's first rule is stated in terms of.
|
||||
///
|
||||
/// **The merge is lossy and that is accepted** (`WatchOrigin.merged`): a foreign edit landing
|
||||
/// inside an app-mediated span arrives labeled `.appMediated` and stays silent. `Motion`
|
||||
/// documents the same blur on the same seam and for the same reason — the alternative is
|
||||
/// splitting deliveries, which `FolderWatcher.schedule` rejects for the coalescing it costs.
|
||||
public var origin: WatchOrigin
|
||||
|
||||
/// Whether this is the reload that closes a bracketed wholesale operation.
|
||||
public var endsBracketedOperation = false
|
||||
|
||||
/// What that operation wants said when it lands — "Pulled 3 commits", "Switched to branch
|
||||
/// 'redesign'". `nil` for a bracket whose completion is not worth speech, which is every
|
||||
/// base-edition bracket today (see `BoardStore.performWholesale(announcing:_:)`).
|
||||
public var completion: String?
|
||||
|
||||
/// The snapshot comparison, empty by default so a test about origins need not build one.
|
||||
public var diff = BoardDiff()
|
||||
|
||||
/// What went out from under the cursor, if anything (`focusOutcome(old:new:selection:focused:)`).
|
||||
public var vanishedFocus: VanishedFocus?
|
||||
|
||||
/// The read-only lock before and after this reload landed. Compared rather than merely
|
||||
/// presence-checked so a lock whose *cause* changed re-announces: "this board's folder is
|
||||
/// gone" replacing "this board's location can't be written to" is news.
|
||||
public var lockBefore: ReadOnlyLockReason?
|
||||
public var lockAfter: ReadOnlyLockReason?
|
||||
|
||||
/// The reload-breakage condition before and after, same rule: a different file failing to
|
||||
/// load is a different sentence and is worth saying.
|
||||
public var breakageBefore: BoardLoadError?
|
||||
public var breakageAfter: BoardLoadError?
|
||||
|
||||
public init(origin: WatchOrigin) {
|
||||
self.origin = origin
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder — see the type's doc comment for the five rungs and why they are in that order.
|
||||
/// `nil` is the answer for the overwhelming majority of reloads, which is the design's intent:
|
||||
/// the board is quiet unless something happened that a user who cannot see it needs told.
|
||||
public static func speech(for facts: ReloadFacts) -> String? {
|
||||
if let raised = raisedCondition(facts) { return raised }
|
||||
if facts.endsBracketedOperation { return facts.completion }
|
||||
if let cleared = clearedCondition(facts) { return cleared }
|
||||
|
||||
// **App-mediated echoes never do.** The user's own action already had its feedback — the
|
||||
// gesture, the animation, the menu it came from — and narrating it back is the app talking
|
||||
// over the user. A reconciling sweep is silent for the neighbouring reason: it makes no claim
|
||||
// that anything changed (a wake, an activation, a missed-events flag), and a board that
|
||||
// announced a digest every time the app came forward would be announcing the *absence* of an
|
||||
// event, the way `Motion` refuses to animate one.
|
||||
guard facts.origin == .foreign else { return nil }
|
||||
|
||||
if let vanished = facts.vanishedFocus {
|
||||
return AccessibilityPhrases.vanishedFocus(vanished)
|
||||
}
|
||||
return AccessibilityPhrases.boardChanged(facts.diff)
|
||||
}
|
||||
|
||||
/// A standing condition that was not there before and is now — announced with the banner row's
|
||||
/// own label, so the sentence a VoiceOver user hears is the sentence the strip is showing.
|
||||
private static func raisedCondition(_ facts: ReloadFacts) -> String? {
|
||||
if let lock = facts.lockAfter, lock != facts.lockBefore {
|
||||
return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: lock))
|
||||
}
|
||||
if let breakage = facts.breakageAfter, breakage != facts.breakageBefore {
|
||||
return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// A standing condition that has healed. The lock leads when both clear at once: it is the one
|
||||
/// that was refusing writes, and "you can edit again" subsumes "it is loading again".
|
||||
private static func clearedCondition(_ facts: ReloadFacts) -> String? {
|
||||
if facts.lockBefore != nil, facts.lockAfter == nil {
|
||||
return AccessibilityPhrases.readOnlyLockCleared
|
||||
}
|
||||
if facts.breakageBefore != nil, facts.breakageAfter == nil {
|
||||
return AccessibilityPhrases.reloadBreakageCleared
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - BoardDiff
|
||||
|
||||
/// **What changed between two snapshots**, as identities grouped by what happened to them — the
|
||||
/// summarizer 10-accessibility.md ▸ Live board announcements asks for ("one polite digest per
|
||||
/// reload debounce — 'Board changed: 2 cards edited, 1 card added'").
|
||||
///
|
||||
/// ### Snapshots, not a journal
|
||||
///
|
||||
/// The watcher reports *that* the tree changed, never *what* (`WatcherEvent`), and 02-architecture.md
|
||||
/// is explicit about why that is the right shape: "a value-type snapshot comparison is both cheaper
|
||||
/// and more trustworthy than trying to reconstruct a tree mutation from a stream of paths that may
|
||||
/// have been coalesced, dropped, or reordered". So this is a comparison of two `BoardModel` values
|
||||
/// and nothing else — no FSEvents paths, no receipts, no git. That is also what makes it work on a
|
||||
/// no-git board, which 10 requires in as many words ("on no-git boards the same classifier runs
|
||||
/// without the committer").
|
||||
///
|
||||
/// ### Why it lives in `LiveStore/` and not beside the announcement
|
||||
///
|
||||
/// 10 says announcements "reuse the auto-committer's summarizer" and 06-history-undo.md says the
|
||||
/// committer synthesizes its commit messages from the same comparison. The committer is Pro-edition
|
||||
/// work (pro-m1) and does not exist yet, so the summarizer is built here first, in the layer both
|
||||
/// consumers can reach: base compiles `Kanban/`, Pro compiles `Kanban/` *plus* `KanbanPro/`
|
||||
/// (12-editions.md ▸ Targets), so a type in the store layer is available to the committer by
|
||||
/// construction while a type in `KanbanPro/` would not be available to the announcer. It imports
|
||||
/// `Foundation` alone and touches no view, no window, and no `NSAccessibility` — the phrasing is
|
||||
/// `AccessibilityPhrases`' job, and pro-m1's message composer will phrase the very same counts
|
||||
/// differently without either of them knowing about the other.
|
||||
///
|
||||
/// The per-category **identity sets** rather than bare tallies are for that second consumer: a
|
||||
/// spoken digest only ever needs `count`, but a semantic commit message needs to name the items, and
|
||||
/// a summarizer that threw the ids away would have to be rewritten rather than extended.
|
||||
///
|
||||
/// ### Two counting rules worth stating
|
||||
///
|
||||
/// - **Implied events don't steal the subject** (06-history-undo.md's composer discipline, which 10
|
||||
/// applies to speech in as many words). A lane that vanished takes its cards with it, and a board
|
||||
/// that says "1 lane deleted, 5 cards deleted" has reported one event twice. So a card is only
|
||||
/// counted as added or deleted when its lane was *there on both sides*; cards arriving with a new
|
||||
/// lane and cards leaving with a deleted one are the lane's event, not their own.
|
||||
/// - **Every changed identity lands in exactly one bucket**, with `moved` outranking `edited`. An
|
||||
/// agent that re-files a card *and* retitles it made one change to the board, and two fragments
|
||||
/// counting the same card would read as two cards. Position wins because it is the change the
|
||||
/// board's shape shows.
|
||||
public struct BoardDiff: Sendable, Equatable {
|
||||
|
||||
/// One kind's four buckets. Disjoint by construction — see the type's note on precedence.
|
||||
public struct Changes: Sendable, Equatable {
|
||||
|
||||
/// Identities present in the new snapshot and not the old — minus the ones whose arrival is
|
||||
/// implied by their lane's.
|
||||
public var added: Set<ItemID> = []
|
||||
|
||||
/// Identities present on both sides whose *rendered content* differs — title, body, style,
|
||||
/// attachments, lane width. Deliberately not `modified`, `modified-by`, or unknown keys: a
|
||||
/// digest describes what the board looks like, and a bumped timestamp changes nothing a
|
||||
/// user could see. Deliberately not `order` either, which is the `moved` axis.
|
||||
public var edited: Set<ItemID> = []
|
||||
|
||||
/// Identities present on both sides that changed position — a different `order`, or (for a
|
||||
/// card) a different lane.
|
||||
public var moved: Set<ItemID> = []
|
||||
|
||||
/// Identities present in the old snapshot and not the new — minus the ones whose departure
|
||||
/// is implied by their lane's. "Deleted" rather than "removed" because that is what it is
|
||||
/// from the board's side: deletion is a move into `.trash/` (01-storage-format.md §
|
||||
/// Deletion), and a folder moved clean out of the board reads the same way to a user.
|
||||
public var deleted: Set<ItemID> = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
added.isEmpty && edited.isEmpty && moved.isEmpty && deleted.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
public var cards = Changes()
|
||||
public var lanes = Changes()
|
||||
|
||||
/// Whether the board side of the snapshot differs **at all** — the backstop for changes no
|
||||
/// bucket counts: a renamed board, an edited board description, a bumped stamp.
|
||||
///
|
||||
/// **The trash is excluded on purpose.** A card entering the trash already counts as a card
|
||||
/// deleted (it left the board's universe) and one leaving it counts as added, which is the
|
||||
/// user's own reading of both; churn *inside* the trash — a purge, an Empty Trash run by an
|
||||
/// agent — moves nothing on the board and is not worth interrupting for. See the report's note:
|
||||
/// a visible trash column changing under a VoiceOver user is a design gap 10 does not rule on.
|
||||
public var boardChanged = false
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Nothing to say: no counted change, and no uncounted one either.
|
||||
public var isSilent: Bool { !boardChanged && cards.isEmpty && lanes.isEmpty }
|
||||
|
||||
// MARK: - The comparison
|
||||
|
||||
/// The whole summarizer: two snapshots in, one grouped diff out. Pure, total, and free of any
|
||||
/// notion of *why* the tree changed — `WatchOrigin` decides whether anyone is told
|
||||
/// (`BoardAnnouncer`), never this.
|
||||
public static func between(_ old: BoardModel, _ new: BoardModel) -> BoardDiff {
|
||||
var diff = BoardDiff()
|
||||
diff.boardChanged = boardSideDiffers(old, new)
|
||||
|
||||
let oldLanes = laneIndex(of: old)
|
||||
let newLanes = laneIndex(of: new)
|
||||
|
||||
for id in newLanes.keys where oldLanes[id] == nil {
|
||||
diff.lanes.added.insert(id)
|
||||
}
|
||||
for id in oldLanes.keys where newLanes[id] == nil {
|
||||
diff.lanes.deleted.insert(id)
|
||||
}
|
||||
for (id, newLane) in newLanes {
|
||||
guard let oldLane = oldLanes[id] else { continue }
|
||||
if oldLane.order != newLane.order {
|
||||
diff.lanes.moved.insert(id)
|
||||
} else if contentDiffers(oldLane, newLane) {
|
||||
diff.lanes.edited.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
let oldCards = cardIndex(of: old)
|
||||
let newCards = cardIndex(of: new)
|
||||
|
||||
for (id, entry) in newCards where oldCards[id] == nil {
|
||||
// The implied-arrival rule: a card that came in with a brand-new lane is the lane's
|
||||
// event.
|
||||
guard !diff.lanes.added.contains(entry.lane) else { continue }
|
||||
diff.cards.added.insert(id)
|
||||
}
|
||||
for (id, entry) in oldCards where newCards[id] == nil {
|
||||
// The implied-departure rule, and the one the vanishing-focus announcement leans on:
|
||||
// when a lane goes, the announcement names the lane and its count, not five cards.
|
||||
guard !diff.lanes.deleted.contains(entry.lane) else { continue }
|
||||
diff.cards.deleted.insert(id)
|
||||
}
|
||||
for (id, newEntry) in newCards {
|
||||
guard let oldEntry = oldCards[id] else { continue }
|
||||
if oldEntry.lane != newEntry.lane || oldEntry.card.order != newEntry.card.order {
|
||||
diff.cards.moved.insert(id)
|
||||
} else if contentDiffers(oldEntry.card, newEntry.card) {
|
||||
diff.cards.edited.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
// MARK: - Indices
|
||||
|
||||
private static func laneIndex(of snapshot: BoardModel) -> [ItemID: Lane] {
|
||||
Dictionary(uniqueKeysWithValues: snapshot.lanes.map { ($0.id, $0) })
|
||||
}
|
||||
|
||||
/// Every board-side card, with the lane it sits in. The trash is not walked: its cards are not
|
||||
/// on the board, and the two crossings that matter (in and out) are already visible as a
|
||||
/// departure and an arrival from this universe.
|
||||
private static func cardIndex(of snapshot: BoardModel) -> [ItemID: (lane: ItemID, card: Card)] {
|
||||
var index: [ItemID: (lane: ItemID, card: Card)] = [:]
|
||||
for lane in snapshot.lanes {
|
||||
for card in lane.cards {
|
||||
index[card.id] = (lane.id, card)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
/// A card's rendered content, field by field rather than by whole-value equality.
|
||||
///
|
||||
/// Spelled out because whole-value equality would fold in `order` (the `moved` axis) and the
|
||||
/// `modified`/`modified-by` stamps, so every reorder would also read as an edit and every
|
||||
/// touch-only rewrite would announce a change nobody could see. A rendered field added to `Card`
|
||||
/// belongs on this list — the cost of the explicitness, recorded so it is not rediscovered.
|
||||
private static func contentDiffers(_ old: Card, _ new: Card) -> Bool {
|
||||
old.schema != new.schema
|
||||
|| old.title != new.title
|
||||
|| old.body != new.body
|
||||
|| old.background != new.background
|
||||
|| old.icon != new.icon
|
||||
|| old.iconColor != new.iconColor
|
||||
|| old.attachments != new.attachments
|
||||
}
|
||||
|
||||
/// A lane's rendered content — the card's list, minus attachments (a lane has none) plus
|
||||
/// `width`, which is a lane's own visible property (03-board-ui.md § Lane). A lane's *cards*
|
||||
/// are diffed as cards, never folded in here.
|
||||
private static func contentDiffers(_ old: Lane, _ new: Lane) -> Bool {
|
||||
old.schema != new.schema
|
||||
|| old.title != new.title
|
||||
|| old.body != new.body
|
||||
|| old.background != new.background
|
||||
|| old.icon != new.icon
|
||||
|| old.iconColor != new.iconColor
|
||||
|| old.width != new.width
|
||||
}
|
||||
|
||||
/// Whether anything on the board side differs, trash excluded.
|
||||
///
|
||||
/// `trash` is the one `var` on `BoardModel`, which is what makes "everything except the trash"
|
||||
/// expressible as value equality rather than as a second field-by-field list that would go
|
||||
/// stale the moment the model grows a field.
|
||||
private static func boardSideDiffers(_ old: BoardModel, _ new: BoardModel) -> Bool {
|
||||
var oldSansTrash = old
|
||||
var newSansTrash = new
|
||||
oldSansTrash.trash = []
|
||||
newSansTrash.trash = []
|
||||
return oldSansTrash != newSansTrash
|
||||
}
|
||||
}
|
||||
@@ -426,6 +426,24 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
private var wholesaleReloadFloor: Int?
|
||||
|
||||
/// What the outstanding wholesale operation wants said when its reload lands, or `nil` for one
|
||||
/// that has nothing to announce — **"bracketed operations announce once, at completion … never
|
||||
/// their internal churn"** (10-accessibility.md ▸ Live board announcements).
|
||||
///
|
||||
/// Stored beside the floor and consumed by the same reload, because the announcement's whole
|
||||
/// claim is that the operation *finished*: a phrase spoken when `performWholesale` returns would
|
||||
/// be describing a tree the store has not read yet, and one spoken per file would be the churn
|
||||
/// the design rules out. It is dropped along with the floor whichever way that reload went — a
|
||||
/// failed closing reload locks the board and says so instead (`BoardAnnouncer`'s ladder puts the
|
||||
/// raised lock above the completion), and the phrase must not survive to be spoken by some
|
||||
/// later, unrelated reload.
|
||||
///
|
||||
/// **`nil` on every base-edition bracket today.** Base has no git operations, and the design's
|
||||
/// examples ("Pulled 3 commits", "Switched to branch 'redesign'") are pro-m1's; the parameter
|
||||
/// exists so that milestone supplies phrasing rather than re-plumbing the seam.
|
||||
@ObservationIgnored
|
||||
private var wholesaleCompletion: String?
|
||||
|
||||
/// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running
|
||||
/// and nothing is owed.
|
||||
@ObservationIgnored
|
||||
@@ -461,6 +479,24 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
var loadBarrier: (@Sendable () async -> Void)?
|
||||
|
||||
/// **This board's one outlet for spoken announcements** — `AccessibilityAnnouncer.post` in
|
||||
/// production, and the second seam this type keeps (`loadBarrier` is the first, and this is the
|
||||
/// same bargain).
|
||||
///
|
||||
/// Every *decision* about what the board says is already a pure function of values
|
||||
/// (`BoardAnnouncer.speech(for:)`, `AccessibilityPhrases`), so the rules are not here and are not
|
||||
/// tested through here. What this makes assertable is the **wiring**: that a foreign reload's
|
||||
/// sentence actually reaches an outlet, that an app-mediated echo produces none, and that a
|
||||
/// bracket's completion phrase is spoken by the reload that closed it and by no later one. Those
|
||||
/// are claims about the reload path rather than about phrasing, and the alternative way to check
|
||||
/// them is a screen reader and a human ear.
|
||||
///
|
||||
/// One outlet rather than a call per producer, for `setTrashVisible`'s own reason: the board's
|
||||
/// announcements are one voice, and a producer that posted around this would be a second voice
|
||||
/// nothing could see.
|
||||
@ObservationIgnored
|
||||
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
|
||||
|
||||
// MARK: - Init
|
||||
@@ -592,15 +628,56 @@ public final class BoardStore {
|
||||
// operation gets exactly one reload to prove itself, and a second failure after it is
|
||||
// ordinary per-file breakage again.
|
||||
let endsWholesaleOperation: Bool
|
||||
let completion: String?
|
||||
if let floor = wholesaleReloadFloor, generation >= floor {
|
||||
wholesaleReloadFloor = nil
|
||||
completion = wholesaleCompletion
|
||||
wholesaleCompletion = nil
|
||||
endsWholesaleOperation = true
|
||||
} else {
|
||||
completion = nil
|
||||
endsWholesaleOperation = false
|
||||
}
|
||||
|
||||
// The two standing conditions as they stood *before* this reload touched them —
|
||||
// 10-accessibility.md makes the live-reload-resilience banner an announced element "when it
|
||||
// appears and when it clears", and appearing and clearing are transitions, not states. Read
|
||||
// here rather than at each mutation below so there is one before-picture for the whole
|
||||
// landing, whichever branch it takes.
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: origin)
|
||||
facts.endsBracketedOperation = endsWholesaleOperation
|
||||
facts.completion = completion
|
||||
facts.lockBefore = readOnlyLock
|
||||
facts.breakageBefore = reloadFailure
|
||||
|
||||
switch outcome {
|
||||
case let .success(result):
|
||||
// **What changed, and what it cost the cursor** — both computed against the *outgoing*
|
||||
// snapshot, so they have to be taken before the assignment below replaces it. Both are
|
||||
// pure functions of two value types; nothing here decides whether anyone is told.
|
||||
//
|
||||
// Asked only of a `.foreign` reload that is not closing a bracket, which is the only
|
||||
// reload either answer is used by: 10-accessibility.md gives the app's own echoes
|
||||
// silence, gives a bracket one sentence at completion rather than a description of its
|
||||
// churn, and phrases the vanishing-focus case as "deleted *externally*" — a sentence that
|
||||
// would be a lie about an app-mediated delete, whose own command already chose a
|
||||
// successor (04-interactions.md ▸ The map's ⌫ rule) and must not have it overridden here.
|
||||
// Skipping the comparison on the other origins keeps the ordinary echo's landing exactly
|
||||
// as cheap as it was.
|
||||
let focus: BoardAnnouncer.FocusOutcome
|
||||
if origin == .foreign, !endsWholesaleOperation {
|
||||
facts.diff = BoardDiff.between(snapshot, result.model)
|
||||
focus = BoardAnnouncer.focusOutcome(
|
||||
old: snapshot,
|
||||
new: result.model,
|
||||
selection: transient.selection,
|
||||
focused: focusedItem
|
||||
)
|
||||
} else {
|
||||
focus = .survived
|
||||
}
|
||||
facts.vanishedFocus = focus.vanished
|
||||
|
||||
// **The motion language's one decision point** (03-board-ui.md § Motion, via `Motion`).
|
||||
// "User-initiated structural changes animate; foreign changes snap" cannot live at the
|
||||
// call sites here the way it did in the pathfinder — the one-way flow means the user's
|
||||
@@ -627,6 +704,12 @@ public final class BoardStore {
|
||||
// re-grounding that landed outside this one would be exactly the independent ease
|
||||
// that rules out.
|
||||
transient.resolve(against: result.model)
|
||||
// And *then* the recovery, on top of the set rule rather than instead of it: the
|
||||
// resolution leaves an emptied selection wherever the focused item used to be, and
|
||||
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
|
||||
// lane … walks up then sideways"). Inside the same transaction for the highlight's
|
||||
// sake, exactly like the resolution it follows.
|
||||
recoverFocus(focus.recovery)
|
||||
}
|
||||
// Outside it, equally deliberately — 03 keys transactions narrowly, and the banner
|
||||
// conditions are not board structure. A lock clearing is not a thing the strip should
|
||||
@@ -684,6 +767,50 @@ public final class BoardStore {
|
||||
}
|
||||
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
|
||||
}
|
||||
|
||||
// **One announcement per reload**, chosen by `BoardAnnouncer`'s precedence ladder and posted
|
||||
// last, after both branches have finished moving the store — so the after-picture the
|
||||
// decision reads is the settled one, and so a sentence is never spoken about a state that a
|
||||
// line below it then changed.
|
||||
facts.lockAfter = readOnlyLock
|
||||
facts.breakageAfter = reloadFailure
|
||||
announce(BoardAnnouncer.speech(for: facts))
|
||||
}
|
||||
|
||||
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
|
||||
/// reload — the storage half of the rule, with every decision already made.
|
||||
///
|
||||
/// The board container case is spelled rather than skipped: `resolve(against:)` has already
|
||||
/// emptied the selection by the time this runs, so `clearSelection()` is a no-op on membership —
|
||||
/// but it also drops the anchor and the head, which is the difference between "nothing is
|
||||
/// selected" and "nothing is selected and the next ⇧-arrow ranges from a ghost".
|
||||
///
|
||||
/// `noteActiveLane` rides along on the lane case for `lastActiveLaneID`'s own reason: the
|
||||
/// resolution just cleared that memory along with the lane it named, and a ⌘N after a foreign
|
||||
/// delete should file the card where the user has been left, not at the far left of the board.
|
||||
private func recoverFocus(_ recovery: BoardAnnouncer.FocusRecovery?) {
|
||||
switch recovery {
|
||||
case nil:
|
||||
break
|
||||
case let .lane(id):
|
||||
transient.select([id], in: .board)
|
||||
transient.noteActiveLane(id)
|
||||
case .boardContainer:
|
||||
transient.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/// **The cursor**, as 10-accessibility.md's announcements mean it: the navigation head when it
|
||||
/// is still in the selection, else a sole selected item, else nothing.
|
||||
///
|
||||
/// Nothing for a multi-item selection with no head deliberately — a vanishing-focus sentence
|
||||
/// names *one* item ("Card 'Fix login' was deleted externally"), and picking one out of a
|
||||
/// five-card selection by set order would name whichever the hash table happened to yield. That
|
||||
/// case falls through to the digest, which describes all five honestly.
|
||||
private var focusedItem: ItemID? {
|
||||
let ids = transient.selection.ids
|
||||
if let head = transient.selectionHead, ids.contains(head) { return head }
|
||||
return ids.count == 1 ? ids.first : nil
|
||||
}
|
||||
|
||||
private func startPendingReload() {
|
||||
@@ -757,7 +884,9 @@ public final class BoardStore {
|
||||
/// which can only happen if the root came back) is strictly the safer one to be holding.
|
||||
public func enterVanishedRootLock() {
|
||||
Self.logger.error("board root vanished — entering the read-only lock")
|
||||
let before = readOnlyLock
|
||||
readOnlyLock = .vanishedRoot
|
||||
announceLockChange(from: before)
|
||||
}
|
||||
|
||||
/// Raises the unwritable-location read-only lock — the open flow's call, after probing the
|
||||
@@ -772,6 +901,23 @@ public final class BoardStore {
|
||||
guard readOnlyLock == nil else { return }
|
||||
Self.logger.error("board location is not writable — entering the read-only lock")
|
||||
readOnlyLock = .unwritableLocation
|
||||
announceLockChange(from: nil)
|
||||
}
|
||||
|
||||
/// Speaks a lock raised **outside** the reload path — the registry's vanished-root call and the
|
||||
/// open flow's writability probe, neither of which is a reload and neither of which therefore
|
||||
/// competes with anything for the debounce's one sentence.
|
||||
///
|
||||
/// 10-accessibility.md makes the live-reload-resilience banner an announced element, and these
|
||||
/// are the two ways it can appear without a reload landing. Routed through the same
|
||||
/// `BoardAnnouncer.ReloadFacts` ladder rather than posting directly so the sentence is composed
|
||||
/// exactly once, in one place, from the banner's own headline: a lock the user hears described
|
||||
/// one way and reads another is two locks as far as they can tell.
|
||||
private func announceLockChange(from before: ReadOnlyLockReason?) {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockBefore = before
|
||||
facts.lockAfter = readOnlyLock
|
||||
announce(BoardAnnouncer.speech(for: facts))
|
||||
}
|
||||
|
||||
// MARK: - Write gate
|
||||
@@ -837,11 +983,20 @@ public final class BoardStore {
|
||||
/// died partway is precisely the case where the tree's state is unknown and the next reload had
|
||||
/// better be the authority on it.
|
||||
///
|
||||
/// - Parameter completion: what to announce when the closing reload lands
|
||||
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
|
||||
/// completion" — "Pulled 3 commits", "Switched to branch 'redesign'"). `nil`, the default, is
|
||||
/// an operation whose completion is not worth speech, which is **every base-edition bracket
|
||||
/// today**: base has no git operations, and the two app-initiated writes that do reach disk on
|
||||
/// their own — the loose-file relocation and the legacy-tombstone migration — are ordinary
|
||||
/// `performWrite` calls that already say what they did on the banner strip. The parameter is
|
||||
/// the seam pro-m1 fills; see `wholesaleCompletion`.
|
||||
///
|
||||
/// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is already locked — a locked
|
||||
/// board refuses to *start* wholesale work, not just ordinary writes. Otherwise rethrows
|
||||
/// `operation`'s error. (Spelled `throws` rather than `rethrows` because of that refusal: a
|
||||
/// `rethrows` function may only throw errors its closure threw.)
|
||||
public func performWholesale(_ operation: () throws -> Void) throws {
|
||||
public func performWholesale(announcing completion: String? = nil, _ operation: () throws -> Void) throws {
|
||||
if let readOnlyLock {
|
||||
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
|
||||
}
|
||||
@@ -849,8 +1004,12 @@ public final class BoardStore {
|
||||
defer {
|
||||
// Ordered: arm first, then close the bracket. `endBracket()` is what schedules the
|
||||
// post-bracket reload, and with a `nil` watcher a consumer may signal by hand the instant
|
||||
// this returns — either way the floor has to be in place before any walk can start.
|
||||
// this returns — either way the floor has to be in place before any walk can start. The
|
||||
// completion phrase is armed with it, for the same reason and on the same exit paths: an
|
||||
// operation that died partway still owes its closing reload, and 10 gives that reload one
|
||||
// sentence whichever way it goes.
|
||||
wholesaleReloadFloor = reloadGeneration + 1
|
||||
wholesaleCompletion = completion
|
||||
watcherBrackets?.end()
|
||||
}
|
||||
do {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: - AccessibilityAnnouncer
|
||||
|
||||
/// **The app's one `NSAccessibility.post` call site** — the thin, untestable half of every spoken
|
||||
/// announcement (10-accessibility.md ▸ Live board announcements, ▸ Trash lane).
|
||||
///
|
||||
/// Everything *decidable* about an announcement — whether one is owed, which of several competing
|
||||
/// sentences wins, and what it says — is a pure function elsewhere (`BoardAnnouncer`,
|
||||
/// `AccessibilityPhrases`). What is left is two lines of AppKit that cannot be asserted about
|
||||
/// without a screen reader attached, so they are stated once, here, rather than copied to every
|
||||
/// producer where they could quietly drift apart.
|
||||
///
|
||||
/// **Medium priority, always.** 10 asks for "one polite (non-interrupting) digest"; `.high` cuts off
|
||||
/// speech already in progress, which is right for a modal failure and wrong for every announcement
|
||||
/// this app makes — a board narrating an agent's edit must never talk over the sentence the user is
|
||||
/// currently listening to.
|
||||
///
|
||||
/// **Posted to the key window** so the announcement is attributed to the board the user is looking
|
||||
/// at rather than to the application at large; `NSApplication.shared` is the fallback for the moment
|
||||
/// between windows, where an attributed announcement is still better than a dropped one.
|
||||
enum AccessibilityAnnouncer {
|
||||
|
||||
/// Says `phrase`, or says nothing.
|
||||
///
|
||||
/// `nil`-and-empty-tolerant on purpose: every producer above it answers with an *optional*
|
||||
/// sentence — "one announcement per reload, and usually none" is the whole shape of
|
||||
/// `BoardAnnouncer.speech(for:)` — so the `nil` check belongs here rather than at each call
|
||||
/// site, where forgetting it would post an empty announcement (a spoken pause) instead of
|
||||
/// staying quiet.
|
||||
@MainActor
|
||||
static func post(_ phrase: String?) {
|
||||
guard let phrase, !phrase.isEmpty else { return }
|
||||
let element: Any = NSApplication.shared.keyWindow ?? NSApplication.shared
|
||||
NSAccessibility.post(
|
||||
element: element,
|
||||
notification: .announcementRequested,
|
||||
userInfo: [
|
||||
.announcement: phrase,
|
||||
.priority: NSAccessibilityPriorityLevel.medium.rawValue
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -121,4 +121,118 @@ enum AccessibilityPhrases {
|
||||
static func trashVisibility(shown: Bool) -> String {
|
||||
shown ? "Trash shown" : "Trash hidden"
|
||||
}
|
||||
|
||||
// MARK: - Live board announcements
|
||||
|
||||
/// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding.
|
||||
/// Spelled here rather than borrowed from `TrashModel.phrase` because that one is a cards-only
|
||||
/// container's phrase by design ("Lanes are never trashed").
|
||||
static func laneCount(_ count: Int) -> String {
|
||||
"\(count) lane\(count == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
/// The digest's opening — and, on its own, the whole sentence for a change no bucket counts.
|
||||
///
|
||||
/// Named for the sentence rather than overloading `boardChanged(_:)`: a constant and a function
|
||||
/// sharing a base name would make `"\(boardChanged)"` an ambiguity a reader has to resolve by
|
||||
/// hand, and string interpolation accepts either.
|
||||
static let boardChangedSubject = "Board changed"
|
||||
|
||||
/// **One polite digest per reload debounce** — 10-accessibility.md ▸ Live board announcements,
|
||||
/// whose own example sentence this reproduces: "Board changed: 2 cards edited, 1 card added".
|
||||
///
|
||||
/// Three phrasing rules, all pinned by `AccessibilityPhrasesTests`:
|
||||
///
|
||||
/// - **Zero categories are omitted**, never spoken as "0 cards moved". A digest is a summary,
|
||||
/// and a summary that lists what did *not* happen is a list of noise with the news buried in
|
||||
/// it.
|
||||
/// - **Counts fold their plural** (`cardCount`, `laneCount`), like every other count in the app.
|
||||
/// - **The order is fixed**: cards before lanes, and within each kind edited, added, moved,
|
||||
/// deleted. Cards lead because they are what a board is mostly made of and what 10's example
|
||||
/// leads with; within a kind the order runs from the change that leaves the board's shape
|
||||
/// alone to the one that takes something out of it, so the sentence ends on the fragment most
|
||||
/// likely to need acting on.
|
||||
///
|
||||
/// `nil` — silence — when nothing differs at all, which is the ordinary outcome of a reload that
|
||||
/// re-read an unchanged tree. A change that no bucket counts (a renamed board, an edited board
|
||||
/// description) still says *something*: "Board changed", bare. Announcing nothing there would be
|
||||
/// the lie 10's principle names — "silence about a mutating board is a lie to a VoiceOver user".
|
||||
static func boardChanged(_ diff: BoardDiff) -> String? {
|
||||
var fragments: [String] = []
|
||||
appendFragments(of: diff.cards, counting: cardCount, to: &fragments)
|
||||
appendFragments(of: diff.lanes, counting: laneCount, to: &fragments)
|
||||
|
||||
guard !fragments.isEmpty else {
|
||||
return diff.boardChanged ? boardChangedSubject : nil
|
||||
}
|
||||
return "\(boardChangedSubject): \(fragments.joined(separator: ", "))"
|
||||
}
|
||||
|
||||
/// One kind's fragments, in the fixed category order. `counting` is the kind's plural folding,
|
||||
/// passed in so the two kinds are one piece of code rather than two that could drift.
|
||||
private static func appendFragments(
|
||||
of changes: BoardDiff.Changes,
|
||||
counting count: (Int) -> String,
|
||||
to fragments: inout [String]
|
||||
) {
|
||||
if !changes.edited.isEmpty { fragments.append("\(count(changes.edited.count)) edited") }
|
||||
if !changes.added.isEmpty { fragments.append("\(count(changes.added.count)) added") }
|
||||
if !changes.moved.isEmpty { fragments.append("\(count(changes.moved.count)) moved") }
|
||||
if !changes.deleted.isEmpty { fragments.append("\(count(changes.deleted.count)) deleted") }
|
||||
}
|
||||
|
||||
/// **A vanishing focus is called out specifically** (10 ▸ Live board announcements) — the
|
||||
/// design's own two sentences, "Card 'Fix login' was deleted externally" and "Lane 'Doing' was
|
||||
/// deleted externally, with 5 cards".
|
||||
///
|
||||
/// The lane form's trailing clause is what makes the substitution honest: when the lane went, it
|
||||
/// took cards with it, and naming the lane *without* the count would hide the larger half of
|
||||
/// what happened. It is omitted at zero — an empty lane vanishing has no second clause to add,
|
||||
/// and "with 0 cards" would be an odd way to say "and nothing else".
|
||||
///
|
||||
/// "Externally" and not "by an agent" or "on disk": the announcement fires only on a foreign
|
||||
/// reload (`BoardAnnouncer.speech(for:)`), and which outside writer did it — an editor, an
|
||||
/// agent, `git` in a terminal — is exactly what the store cannot know.
|
||||
static func vanishedFocus(_ vanished: BoardAnnouncer.VanishedFocus) -> String {
|
||||
switch vanished {
|
||||
case let .card(title):
|
||||
"Card '\(displayTitle(title))' was deleted externally"
|
||||
case let .lane(title, cards):
|
||||
cards == 0
|
||||
? "Lane '\(displayTitle(title))' was deleted externally"
|
||||
: "Lane '\(displayTitle(title))' was deleted externally, with \(cardCount(cards))"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The banner strip
|
||||
|
||||
/// What VoiceOver says before a banner's headline. "Status" rather than "Info" because that is
|
||||
/// the word the platform uses for a non-alarming state announcement.
|
||||
///
|
||||
/// Moved here from `BannerStripView` when the strip's rows started being *announced* as well as
|
||||
/// read: 10 makes the live-reload-resilience banner "an accessibility element … announced when
|
||||
/// it appears and when it clears", and the row's label and its announcement must be the same
|
||||
/// sentence or a user would hear the condition described two different ways.
|
||||
static func bannerTonePrefix(_ tone: BannerTone) -> String {
|
||||
switch tone {
|
||||
case .error: "Error"
|
||||
case .warning: "Warning"
|
||||
case .info: "Status"
|
||||
}
|
||||
}
|
||||
|
||||
/// A banner row as one spoken element — tone first, because a VoiceOver user must hear *that*
|
||||
/// this is an error before hearing what the error is, and colour cannot carry that.
|
||||
static func bannerLabel(tone: BannerTone, headline: String) -> String {
|
||||
"\(bannerTonePrefix(tone)): \(headline)"
|
||||
}
|
||||
|
||||
/// The read-only lock clearing. Stated as the regained capability rather than as the cause's
|
||||
/// disappearance ("the volume came back") because the causes are three and the consequence is
|
||||
/// one, and the consequence is what the user was waiting on.
|
||||
static let readOnlyLockCleared = "The board is editable again"
|
||||
|
||||
/// Reload breakage clearing — the board is reading its files again, which is a smaller claim
|
||||
/// than the lock's and is deliberately phrased as one.
|
||||
static let reloadBreakageCleared = "The board is loading again"
|
||||
}
|
||||
|
||||
@@ -61,6 +61,15 @@ public struct BannerStripView: View {
|
||||
}
|
||||
}
|
||||
.background(.quaternary)
|
||||
// **The strip is an accessibility element** (10-accessibility.md ▸ Live board
|
||||
// announcements, which makes the live-reload-resilience banner one by name). `.contain`
|
||||
// rather than `.combine`: the rows stay individually focusable — each is already one
|
||||
// element with its own tone-prefixed label, and a strip that fused three conditions into
|
||||
// one utterance would bury the lock under the signposts. The group label is what a
|
||||
// VoiceOver user hears on entering it, so "there are conditions here" arrives before the
|
||||
// conditions do.
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Board status")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +145,15 @@ private struct BannerRowView: View {
|
||||
.background(row.tone.fill)
|
||||
// One element per row, tone included: a VoiceOver user must hear *that* this is an error
|
||||
// before hearing what the error is, and colour cannot carry that. The dismiss and Cancel
|
||||
// buttons survive as custom actions of the combined element rather than as separate stops
|
||||
// (10-accessibility.md; the announce-on-appear path arrives with that milestone).
|
||||
// buttons survive as custom actions of the combined element rather than as separate stops.
|
||||
//
|
||||
// The label is composed by `AccessibilityPhrases` rather than spelled here because the two
|
||||
// standing conditions are also *announced* on arrival and clearance (10-accessibility.md ▸
|
||||
// Live board announcements, via `BoardAnnouncer`): the sentence a user hears when the lock
|
||||
// appears and the sentence they read off the row afterwards are one string, or they are two
|
||||
// descriptions of one condition waiting to disagree.
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text("\(row.tone.accessibilityPrefix): \(row.headline)"))
|
||||
.accessibilityLabel(Text(AccessibilityPhrases.bannerLabel(tone: row.tone, headline: row.headline)))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -210,16 +224,6 @@ private extension BannerTone {
|
||||
case .info: AnyShapeStyle(.background.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// What VoiceOver says before the headline. "Status" rather than "Info" because that is the
|
||||
/// word the platform uses for a non-alarming state announcement.
|
||||
var accessibilityPrefix: String {
|
||||
switch self {
|
||||
case .error: "Error"
|
||||
case .warning: "Warning"
|
||||
case .info: "Status"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@@ -291,32 +290,17 @@ extension BoardStore {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
announceTrashVisibility(shown)
|
||||
}
|
||||
|
||||
/// **"Toggling visibility is announced"** (10-accessibility.md ▸ Trash lane).
|
||||
///
|
||||
/// A whole container joins or leaves the accessibility tree here and nothing else marks it: the
|
||||
/// VoiceOver cursor does not move, no focus is lost, and the re-divide every lane performs is
|
||||
/// silent by nature. Announced from the store rather than from either caller for
|
||||
/// `setTrashVisible`'s own reason — the View menu row and the toolbar item are one command with
|
||||
/// two faces, and a consequence written at one of them would be missing from the other.
|
||||
///
|
||||
/// One post, and deliberately no machinery around it: the live board's announcements — foreign
|
||||
/// edits, vanishing focus, bracketed operations — are their own design (10 ▸ Live board
|
||||
/// announcements) with a summarizer and a debounce behind them, and this is not an instalment of
|
||||
/// that. Posted to the key window so it is attributed to the board the user is looking at, at
|
||||
/// medium priority: informative, and not worth interrupting speech already in progress.
|
||||
private func announceTrashVisibility(_ shown: Bool) {
|
||||
let element: Any = NSApplication.shared.keyWindow ?? NSApplication.shared
|
||||
NSAccessibility.post(
|
||||
element: element,
|
||||
notification: .announcementRequested,
|
||||
userInfo: [
|
||||
.announcement: AccessibilityPhrases.trashVisibility(shown: shown),
|
||||
.priority: NSAccessibilityPriorityLevel.medium.rawValue
|
||||
]
|
||||
)
|
||||
// **"Toggling visibility is announced"** (10-accessibility.md ▸ Trash lane). A whole
|
||||
// container joins or leaves the accessibility tree here and nothing else marks it: the
|
||||
// VoiceOver cursor does not move, no focus is lost, and the re-divide every lane performs is
|
||||
// silent by nature. Announced from the store rather than from either caller for
|
||||
// `setTrashVisible`'s own reason — the View menu row and the toolbar item are one command
|
||||
// with two faces, and a consequence written at one of them would be missing from the other.
|
||||
//
|
||||
// Deliberately **not** on the reload's one-announcement-per-debounce ladder
|
||||
// (`BoardAnnouncer`): this is a view state the user just toggled, not a change to the board's
|
||||
// files, so there is no origin to classify and nothing for it to compete with.
|
||||
announce(AccessibilityPhrases.trashVisibility(shown: shown))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,4 +134,131 @@ struct AccessibilityPhrasesTests {
|
||||
#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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The live board's speech, as decisions — 10-accessibility.md ▸ Live board announcements:
|
||||
///
|
||||
/// > Foreign changes announce, app-mediated echoes never do … one polite (non-interrupting) digest
|
||||
/// > per reload debounce … A vanishing focus is called out specifically … when the lane itself
|
||||
/// > vanished, recovery walks up then sideways … Bracketed operations announce once, at completion —
|
||||
/// > never their internal churn. The live-reload-resilience banner is an accessibility element and
|
||||
/// > is announced when it appears and when it clears.
|
||||
///
|
||||
/// Every one of those is a rule about *what to say and where to put focus*, and `BoardAnnouncer`
|
||||
/// answers both without a window or a screen reader. The `NSAccessibility.post` on the other side of
|
||||
/// it is two lines with nothing to decide (`AccessibilityAnnouncer`) and is deliberately not tested.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private let lane1 = Ident.lane1
|
||||
private let lane2 = Ident.lane2
|
||||
private let lane3 = Ident.lane3
|
||||
private let card1 = Ident.card1
|
||||
private let card2 = Ident.card2
|
||||
private let card3 = Ident.card3
|
||||
|
||||
private let lane1ID = ItemID(rawValue: lane1)
|
||||
private let lane2ID = ItemID(rawValue: lane2)
|
||||
private let lane3ID = ItemID(rawValue: lane3)
|
||||
private let card1ID = ItemID(rawValue: card1)
|
||||
private let card2ID = ItemID(rawValue: card2)
|
||||
private let card3ID = ItemID(rawValue: card3)
|
||||
|
||||
/// Three lanes left to right — Todo (2 cards), Doing (1 card), Done (empty).
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.board()
|
||||
try fixture.lane(lane1, order: "1024", title: "Todo")
|
||||
try fixture.lane(lane2, order: "2048", title: "Doing")
|
||||
try fixture.lane(lane3, order: "3072", title: "Done")
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Fix login")
|
||||
try fixture.card(card2, in: lane1, order: "2048", title: "Second")
|
||||
try fixture.card(card3, in: lane2, order: "1024", title: "Third")
|
||||
return fixture
|
||||
}
|
||||
|
||||
private func selection(_ ids: Set<ItemID>, in container: ItemContainer = .board) -> ItemReferenceSet {
|
||||
ItemReferenceSet(ids: ids, container: container)
|
||||
}
|
||||
|
||||
// MARK: - Focus
|
||||
|
||||
@Suite("BoardAnnouncer — a vanishing focus")
|
||||
struct BoardAnnouncerFocusTests {
|
||||
|
||||
@Test("A focus that survived the reload says nothing and moves nothing")
|
||||
func survivingFocus() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Fix login again")
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID]),
|
||||
focused: card1ID
|
||||
)
|
||||
|
||||
#expect(outcome == .survived)
|
||||
}
|
||||
|
||||
@Test("A vanished card names itself and recovers to its lane")
|
||||
func vanishedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID]),
|
||||
focused: card1ID
|
||||
)
|
||||
|
||||
#expect(outcome.vanished == .card(title: "Fix login"))
|
||||
#expect(outcome.recovery == .lane(lane1ID))
|
||||
}
|
||||
|
||||
/// "The announcement then names the *lane*, not the card" — the implied-events discipline
|
||||
/// applied to speech, and the reason `VanishedFocus` has two cases rather than a card case with
|
||||
/// a lane attached.
|
||||
@Test("A card that went with its lane names the lane, and its count")
|
||||
func vanishedLaneStealsTheSubject() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(lane1))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID]),
|
||||
focused: card1ID
|
||||
)
|
||||
|
||||
#expect(outcome.vanished == .lane(title: "Todo", cards: 2))
|
||||
#expect(outcome.recovery == .lane(lane2ID), "up, then sideways: the next lane by order")
|
||||
}
|
||||
|
||||
@Test("A focused lane that vanished is the lane case outright")
|
||||
func focusedLaneVanished() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(lane2))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([lane2ID]),
|
||||
focused: lane2ID
|
||||
)
|
||||
|
||||
#expect(outcome.vanished == .lane(title: "Doing", cards: 1))
|
||||
#expect(outcome.recovery == .lane(lane3ID))
|
||||
}
|
||||
|
||||
@Test("An emptied lane vanishing still names the lane, with no count clause to add")
|
||||
func vanishedEmptyLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(lane3))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([lane3ID]),
|
||||
focused: lane3ID
|
||||
)
|
||||
|
||||
#expect(outcome.vanished == .lane(title: "Done", cards: 0))
|
||||
}
|
||||
|
||||
/// The sideways half of the walk: there is no next lane, so the previous one takes the position.
|
||||
@Test("The last lane vanishing recovers to the previous one")
|
||||
func lastLaneRecoversBackwards() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(lane3))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([lane3ID]),
|
||||
focused: lane3ID
|
||||
)
|
||||
|
||||
#expect(outcome.recovery == .lane(lane2ID))
|
||||
}
|
||||
|
||||
/// "The board container only when no lanes remain."
|
||||
@Test("A board emptied of lanes recovers to the board container")
|
||||
func emptiedBoardRecoversToTheContainer() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
for lane in [lane1, lane2, lane3] {
|
||||
try FileManager.default.removeItem(at: fixture.url(lane))
|
||||
}
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID]),
|
||||
focused: card1ID
|
||||
)
|
||||
|
||||
#expect(outcome.vanished == .lane(title: "Todo", cards: 2))
|
||||
#expect(outcome.recovery == .boardContainer)
|
||||
}
|
||||
|
||||
/// A checkout-shaped reload: every old lane is gone and different ones are there. The container
|
||||
/// is reserved for "no lanes remain", so focus lands on the leftmost lane that *is* there.
|
||||
@Test("Lanes replaced wholesale recover to the leftmost surviving lane, not to the container")
|
||||
func replacedLanesRecoverToTheFirstLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
for lane in [lane1, lane2, lane3] {
|
||||
try FileManager.default.removeItem(at: fixture.url(lane))
|
||||
}
|
||||
try fixture.lane(Ident.lane4, order: "1024", title: "Inbox")
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID]),
|
||||
focused: card1ID
|
||||
)
|
||||
|
||||
#expect(outcome.recovery == .lane(ItemID(rawValue: Ident.lane4)))
|
||||
}
|
||||
|
||||
/// 02-architecture.md's re-resolution rule stands where it always did: a selection with
|
||||
/// survivors is still the user's selection, and a reload may not re-aim it.
|
||||
@Test("Surviving selection members veto the recovery")
|
||||
func survivorsVetoRecovery() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID, card2ID]),
|
||||
focused: card1ID
|
||||
)
|
||||
|
||||
#expect(outcome == .survived)
|
||||
}
|
||||
|
||||
@Test("A trash selection never announces and never recovers — focus stays out of the trash")
|
||||
func trashSelectionIsOutOfScope() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.trashCard(card1, order: "1024", title: "Old")
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(".trash/\(card1)"))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID], in: .trash),
|
||||
focused: card1ID
|
||||
)
|
||||
|
||||
#expect(outcome == .survived)
|
||||
}
|
||||
|
||||
@Test("No cursor, no sentence — a multi-item selection with no head falls through to the digest")
|
||||
func noFocusedItem() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
let outcome = BoardAnnouncer.focusOutcome(
|
||||
old: before,
|
||||
new: try fixture.snapshot(),
|
||||
selection: selection([card1ID]),
|
||||
focused: nil
|
||||
)
|
||||
|
||||
#expect(outcome == .survived)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The ladder
|
||||
|
||||
@Suite("BoardAnnouncer — one sentence per reload")
|
||||
struct BoardAnnouncerSpeechTests {
|
||||
|
||||
/// A digest with something in it, so "silent" in the tests below is never silent by accident.
|
||||
private func loudDiff() -> BoardDiff {
|
||||
var diff = BoardDiff()
|
||||
diff.boardChanged = true
|
||||
diff.cards.edited = [card1ID, card2ID]
|
||||
diff.cards.added = [card3ID]
|
||||
return diff
|
||||
}
|
||||
|
||||
private func breakage() -> BoardLoadError {
|
||||
BoardLoadError(path: "Todo/index.md", reason: .missingOrder)
|
||||
}
|
||||
|
||||
// MARK: Origins
|
||||
|
||||
@Test("A foreign reload speaks its digest — the design's own example sentence")
|
||||
func foreignReloadSpeaks() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added")
|
||||
}
|
||||
|
||||
@Test("App-mediated echoes never announce — the user's own action is not news")
|
||||
func appMediatedIsSilent() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
@Test("A reconciling sweep is silent — it claims nothing changed")
|
||||
func reconcilingIsSilent() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .reconciling)
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
@Test("A foreign reload that changed nothing says nothing")
|
||||
func quietForeignReload() {
|
||||
let facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
// MARK: Specific beats generic
|
||||
|
||||
@Test("The vanishing-focus sentence displaces the digest — one reload, one sentence")
|
||||
func vanishingFocusBeatsTheDigest() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.diff = loudDiff()
|
||||
facts.vanishedFocus = .card(title: "Fix login")
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Card 'Fix login' was deleted externally")
|
||||
}
|
||||
|
||||
// MARK: Brackets
|
||||
|
||||
@Test("A bracketed operation announces its completion, never its churn")
|
||||
func bracketAnnouncesCompletion() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Pulled 3 commits"
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Pulled 3 commits")
|
||||
}
|
||||
|
||||
/// Every base-edition bracket today. The seam exists; pro-m1 supplies the phrases.
|
||||
@Test("A bracket with no phrase to say stays silent rather than falling back to the digest")
|
||||
func bracketWithoutAPhrase() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.endsBracketedOperation = true
|
||||
facts.diff = loudDiff()
|
||||
facts.vanishedFocus = .card(title: "Fix login")
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
// MARK: Standing conditions
|
||||
|
||||
@Test("A raised read-only lock outranks everything else the reload could say")
|
||||
func raisedLockWins() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Pulled 3 commits"
|
||||
facts.lockAfter = .bracketedReloadFailed
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== "Error: This board couldn't be re-read after the last operation — showing the last good view, read-only"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("The banner's announcement is the banner's own label — one condition, one sentence")
|
||||
func announcementMatchesTheRowLabel() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockAfter = .vanishedRoot
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: .vanishedRoot))
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A lock that was already standing is not repeated on every reload")
|
||||
func standingLockIsNotRepeated() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockBefore = .vanishedRoot
|
||||
facts.lockAfter = .vanishedRoot
|
||||
facts.diff = { var diff = BoardDiff(); diff.boardChanged = true; return diff }()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Board changed")
|
||||
}
|
||||
|
||||
@Test("A lock whose cause changed is news again")
|
||||
func changedLockCauseSpeaksAgain() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockBefore = .unwritableLocation
|
||||
facts.lockAfter = .vanishedRoot
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== "Error: This board's folder is gone — showing the last good view, read-only"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Reload breakage announces on arrival")
|
||||
func raisedBreakage() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.breakageAfter = breakage()
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage()))
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears")
|
||||
func clearedLock() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockBefore = .bracketedReloadFailed
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "The board is editable again")
|
||||
}
|
||||
|
||||
@Test("Cleared breakage is announced, below the lock when both heal at once")
|
||||
func clearedBreakage() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.breakageBefore = breakage()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "The board is loading again")
|
||||
|
||||
facts.lockBefore = .bracketedReloadFailed
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "The board is editable again")
|
||||
}
|
||||
|
||||
/// A completion phrase already implies the board is reading and writing again, so it takes the
|
||||
/// one sentence the reload has.
|
||||
@Test("A completion phrase outranks the clearance it implies")
|
||||
func completionOutranksClearance() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Switched to branch 'redesign'"
|
||||
facts.lockBefore = .bracketedReloadFailed
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Switched to branch 'redesign'")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - At the reload seam
|
||||
|
||||
/// The half that is not a value function: **focus actually moves**. Driven through a real
|
||||
/// `BoardStore` over a real temp board, like `TransientBoardStateTests`, because the recovery's
|
||||
/// contract includes running *after* `TransientBoardState.resolve(against:)` on the reload path —
|
||||
/// a suite that called the pure function directly could pass with that wire cut.
|
||||
@MainActor
|
||||
@Suite("BoardAnnouncer — at the reload seam")
|
||||
struct BoardAnnouncerStoreTests {
|
||||
|
||||
/// What the board said, read off `BoardStore.announce` — a class rather than a captured local so
|
||||
/// the escaping outlet and the assertions look at one value.
|
||||
@MainActor
|
||||
private final class SpokenLog {
|
||||
var lines: [String] = []
|
||||
|
||||
/// `nil` is the store deciding to say nothing, and is recorded as nothing — the outlet's own
|
||||
/// tolerance, so a test that expects silence expects an empty log rather than `[nil]`.
|
||||
func record(_ phrase: String?) {
|
||||
if let phrase { lines.append(phrase) }
|
||||
}
|
||||
}
|
||||
|
||||
private func listen(to store: BoardStore) -> SpokenLog {
|
||||
let log = SpokenLog()
|
||||
store.announce = { log.record($0) }
|
||||
return log
|
||||
}
|
||||
|
||||
private func reload(_ store: BoardStore, _ origin: WatchOrigin = .foreign) async {
|
||||
store.handleWatcherEvent(.treeChanged(origin))
|
||||
await store.awaitQuiescence()
|
||||
}
|
||||
|
||||
@Test("A foreign delete of the selected card leaves focus on its lane")
|
||||
func recoversToTheLane() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.selection.ids == [lane1ID])
|
||||
#expect(store.transient.lastActiveLaneID == lane1ID, "⌘N after the surprise files where the user was left")
|
||||
}
|
||||
|
||||
@Test("A foreign delete of the selected card's lane walks up, then sideways")
|
||||
func recoversSideways() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(lane1))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.selection.ids == [lane2ID])
|
||||
}
|
||||
|
||||
/// The app's own delete already chose its successor (04-interactions.md ▸ The map); a recovery
|
||||
/// firing on the echo would override it. 02-architecture.md's silent-vanish rule is what an
|
||||
/// app-mediated reload still gets.
|
||||
@Test("An app-mediated echo leaves the emptied selection exactly as the set rule left it")
|
||||
func appMediatedDoesNotRecover() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(store.transient.selection.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A reload that leaves the selection standing never re-aims it")
|
||||
func survivorsAreLeftAlone() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID, card2ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.selection.ids == [card2ID])
|
||||
}
|
||||
|
||||
// MARK: What actually gets said
|
||||
|
||||
@Test("A foreign reload speaks its digest exactly once")
|
||||
func foreignReloadSpeaksOnce() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Renamed")
|
||||
try fixture.card(card2, in: lane1, order: "2048", title: "Also renamed")
|
||||
await reload(store)
|
||||
|
||||
#expect(log.lines == ["Board changed: 2 cards edited"])
|
||||
}
|
||||
|
||||
@Test("An app-mediated echo says nothing at all")
|
||||
func appMediatedEchoIsSilent() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Renamed")
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(log.lines.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A reload that changed nothing says nothing")
|
||||
func quietReloadIsSilent() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
await reload(store)
|
||||
|
||||
#expect(log.lines.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The vanishing-focus sentence is the one the reload spends its sentence on")
|
||||
func vanishingFocusIsWhatIsHeard() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
let log = listen(to: store)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
await reload(store)
|
||||
|
||||
#expect(log.lines == ["Card 'Fix login' was deleted externally"])
|
||||
}
|
||||
|
||||
/// The seam pro-m1 fills. No base-edition operation passes a phrase today, so this is the one
|
||||
/// place the completion path is exercised end to end — including that the phrase is *consumed*
|
||||
/// rather than left armed for whatever reload comes next.
|
||||
@Test("A bracketed operation announces at completion, once, and never again")
|
||||
func bracketSpeaksOnceAtCompletion() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try store.performWholesale(announcing: "Pulled 3 commits") {
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "From the remote")
|
||||
}
|
||||
await reload(store, .appMediated)
|
||||
#expect(log.lines == ["Pulled 3 commits"], "the operation's result, not the churn inside it")
|
||||
|
||||
log.lines.removeAll()
|
||||
try fixture.card(card2, in: lane1, order: "2048", title: "An agent's edit")
|
||||
await reload(store)
|
||||
#expect(log.lines == ["Board changed: 1 card edited"], "the phrase was consumed, not carried forward")
|
||||
}
|
||||
|
||||
@Test("A bracket with no phrase to say stays quiet — every base-edition bracket today")
|
||||
func silentBracket() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try store.performWholesale {
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Rewritten")
|
||||
}
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(log.lines.isEmpty)
|
||||
}
|
||||
|
||||
/// "Including the read-only lock after a failed bracketed reload" — the banner appears, and the
|
||||
/// announcement is the row's own sentence.
|
||||
@Test("A failed bracketed reload announces the lock instead of the operation")
|
||||
func failedBracketAnnouncesTheLock() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try store.performWholesale(announcing: "Pulled 3 commits") {
|
||||
// A lane with no `order` fails the whole load (01-storage-format.md § Malformed input).
|
||||
try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n")
|
||||
}
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
||||
#expect(
|
||||
log.lines == [
|
||||
"Error: This board couldn't be re-read after the last operation — showing the last good view, read-only"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("The lock's clearance is announced too")
|
||||
func clearedLockIsAnnounced() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
try store.performWholesale {
|
||||
try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n")
|
||||
}
|
||||
await reload(store, .appMediated)
|
||||
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
||||
|
||||
let log = listen(to: store)
|
||||
try fixture.lane(lane1, order: "1024", title: "Todo")
|
||||
await reload(store)
|
||||
|
||||
#expect(store.readOnlyLock == nil)
|
||||
#expect(log.lines == ["The board is editable again"], "the healing outranks the digest of what healed it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The snapshot summarizer — 10-accessibility.md ▸ Live board announcements' "one polite digest per
|
||||
/// reload debounce", and (later) pro-m1's commit-message engine, whose counting rules these are too.
|
||||
///
|
||||
/// Every case here is **two real loads of a real board**, written and mutated on disk the way an
|
||||
/// outside writer mutates one: `BoardDiff` compares what a reload compares, and a suite that
|
||||
/// hand-built its `BoardModel` values could pass while disagreeing with the loader about what a
|
||||
/// board even is.
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
private let lane1 = Ident.lane1
|
||||
private let lane2 = Ident.lane2
|
||||
private let lane3 = Ident.lane3
|
||||
private let card1 = Ident.card1
|
||||
private let card2 = Ident.card2
|
||||
private let card3 = Ident.card3
|
||||
private let card4 = Ident.card4
|
||||
|
||||
/// Three lanes; two cards in the first, one in the second, none in the third.
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.board()
|
||||
try fixture.lane(lane1, order: "1024", title: "Todo")
|
||||
try fixture.lane(lane2, order: "2048", title: "Doing")
|
||||
try fixture.lane(lane3, order: "3072", title: "Done")
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "First", body: "one")
|
||||
try fixture.card(card2, in: lane1, order: "2048", title: "Second", body: "two")
|
||||
try fixture.card(card3, in: lane2, order: "1024", title: "Third", body: "three")
|
||||
return fixture
|
||||
}
|
||||
|
||||
@Suite("BoardDiff")
|
||||
struct BoardDiffTests {
|
||||
|
||||
// MARK: - Nothing happened
|
||||
|
||||
@Test("Two reads of an untouched board are silent")
|
||||
func unchangedBoardIsSilent() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let diff = BoardDiff.between(try fixture.snapshot(), try fixture.snapshot())
|
||||
|
||||
#expect(diff.isSilent)
|
||||
#expect(!diff.boardChanged)
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
@Test("A retitled card is one edit")
|
||||
func retitledCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Renamed", body: "one")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.edited == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.added.isEmpty && diff.cards.moved.isEmpty && diff.cards.deleted.isEmpty)
|
||||
#expect(diff.lanes.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An edited body is an edit — the card's content is the whole point of one")
|
||||
func editedBody() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "First", body: "rewritten by an agent")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.edited == [ItemID(rawValue: card1)])
|
||||
}
|
||||
|
||||
/// The `modified` stamp is not on the board, so bumping it is not a change the board can show.
|
||||
/// It still trips `boardChanged`, which is the backstop's job — see `bareBoardChange`.
|
||||
@Test("A bumped timestamp is not an edit")
|
||||
func touchedTimestampIsNotAnEdit() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "First", body: "one", modified: "2026-07-29T09:00:00Z")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.isEmpty)
|
||||
#expect(diff.boardChanged, "the snapshot did differ — the backstop still fires")
|
||||
}
|
||||
|
||||
@Test("A new card in an existing lane is one addition")
|
||||
func addedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card4, in: lane2, order: "2048", title: "Fourth")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.added == [ItemID(rawValue: card4)])
|
||||
#expect(diff.cards.edited.isEmpty && diff.cards.moved.isEmpty)
|
||||
}
|
||||
|
||||
/// Deletion is a move into `.trash/` (01-storage-format.md § Deletion): the card leaves the
|
||||
/// board's universe, which is the only thing the digest describes.
|
||||
@Test("A card moved into the trash reads as deleted from the board")
|
||||
func deletedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.deleted == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.added.isEmpty, "it did not also arrive somewhere")
|
||||
}
|
||||
|
||||
@Test("A card moved between lanes is one move, not an addition plus a deletion")
|
||||
func movedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.moveFolder("\(lane1)/\(card1)", to: "\(lane2)/\(card1)")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.moved == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.added.isEmpty && diff.cards.deleted.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A reordered card is moved, not edited — `order` is the position axis")
|
||||
func reorderedCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "4096", title: "First", body: "one")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.moved == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.edited.isEmpty)
|
||||
}
|
||||
|
||||
/// One card, one change to the board — two fragments counting it would read as two cards.
|
||||
@Test("A card that both moved and was retitled counts once, as moved")
|
||||
func movedAndEditedCountsOnce() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.moveFolder("\(lane1)/\(card1)", to: "\(lane2)/\(card1)")
|
||||
try fixture.card(card1, in: lane2, order: "1024", title: "Renamed", body: "one")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.cards.moved == [ItemID(rawValue: card1)])
|
||||
#expect(diff.cards.edited.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Lanes
|
||||
|
||||
@Test("A retitled lane is one lane edit and touches no card")
|
||||
func retitledLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(lane1, order: "1024", title: "Backlog")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.edited == [ItemID(rawValue: lane1)])
|
||||
#expect(diff.cards.isEmpty, "a lane's cards are diffed as cards, never folded into its own change")
|
||||
}
|
||||
|
||||
@Test("A resized lane is an edit — width is a lane's own visible property")
|
||||
func resizedLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(lane1, order: "1024", title: "Todo", width: 2)
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.edited == [ItemID(rawValue: lane1)])
|
||||
}
|
||||
|
||||
@Test("A reordered lane is moved, not edited")
|
||||
func reorderedLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(lane1, order: "5120", title: "Todo")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.moved == [ItemID(rawValue: lane1)])
|
||||
#expect(diff.lanes.edited.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Implied events don't steal the subject
|
||||
|
||||
/// 06-history-undo.md's composer discipline, which 10-accessibility.md applies to speech: a
|
||||
/// board that says "1 lane deleted, 2 cards deleted" has reported one event twice.
|
||||
@Test("A deleted lane does not also count the cards that went with it")
|
||||
func deletedLaneSwallowsItsCards() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(lane1))
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
|
||||
#expect(diff.cards.deleted.isEmpty, "the two cards left with their lane — that is the lane's event")
|
||||
}
|
||||
|
||||
@Test("A new lane does not also count the cards that arrived in it")
|
||||
func addedLaneSwallowsItsCards() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(Ident.lane4, order: "4096", title: "Blocked")
|
||||
try fixture.card(card4, in: Ident.lane4, order: "1024", title: "Fourth")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.added == [ItemID(rawValue: Ident.lane4)])
|
||||
#expect(diff.cards.added.isEmpty)
|
||||
}
|
||||
|
||||
/// The complement, and the reason the rule is stated on the *lane's* membership rather than on
|
||||
/// the card's presence: a card that survived by moving into the new lane is its own event.
|
||||
@Test("A card that moved into a new lane is still a move of its own")
|
||||
func cardMovedIntoANewLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.lane(Ident.lane4, order: "4096", title: "Blocked")
|
||||
try fixture.moveFolder("\(lane1)/\(card1)", to: "\(Ident.lane4)/\(card1)")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.lanes.added == [ItemID(rawValue: Ident.lane4)])
|
||||
#expect(diff.cards.moved == [ItemID(rawValue: card1)])
|
||||
}
|
||||
|
||||
// MARK: - The trash and the backstop
|
||||
|
||||
/// A purge moves nothing on the board, and the trash column is hidden by default — not worth
|
||||
/// interrupting a VoiceOver user for.
|
||||
@Test("Churn inside the trash is not a change to the board")
|
||||
func trashChurnIsSilent() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.trashCard(card4, order: "1024", title: "Old")
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url(".trash/\(card4)"))
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.isSilent)
|
||||
}
|
||||
|
||||
/// "Silence about a mutating board is a lie" — so a change no bucket counts still reports
|
||||
/// *something*, which `AccessibilityPhrases.boardChanged(_:)` speaks as the bare sentence.
|
||||
@Test("A renamed board is a bare board change with no buckets")
|
||||
func bareBoardChange() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.snapshot()
|
||||
|
||||
try fixture.board(title: "Renamed")
|
||||
let diff = BoardDiff.between(before, try fixture.snapshot())
|
||||
|
||||
#expect(diff.boardChanged)
|
||||
#expect(diff.cards.isEmpty && diff.lanes.isEmpty)
|
||||
#expect(!diff.isSilent)
|
||||
}
|
||||
}
|
||||
@@ -388,8 +388,13 @@ struct SelectionHeadTests {
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(store.selection.isEmpty)
|
||||
#expect(store.transient.selectionHead == nil)
|
||||
// The crossing dropped the head, which is this test's rule. What stands afterwards is
|
||||
// 10-accessibility.md's vanishing-focus recovery, layered on top: the selection had emptied
|
||||
// and the cursor's card had gone, so focus lands on the card's lane and takes the head with
|
||||
// it (`BoardAnnouncerStoreTests`, where the recovery itself is pinned). The card's own head
|
||||
// reference is gone either way, which is what "a container crossing is a vanish" claims.
|
||||
#expect(store.selection.ids == [lane1])
|
||||
#expect(store.transient.selectionHead == lane1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,8 +465,12 @@ struct SelectionAnchorTests {
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||
await reload(store)
|
||||
#expect(store.selection.isEmpty)
|
||||
#expect(store.transient.selectionAnchor == nil)
|
||||
// The anchor's card is gone — the rule this test exists for. The selection is not empty
|
||||
// afterwards because the reload emptied it *and* took the cursor with it, which is
|
||||
// 10-accessibility.md's vanishing-focus case: focus recovers to the card's lane, and a
|
||||
// one-item selection made by any route is its own anchor (`BoardAnnouncerStoreTests`).
|
||||
#expect(store.selection.ids == [lane1])
|
||||
#expect(store.transient.selectionAnchor == lane1)
|
||||
}
|
||||
|
||||
@Test("A container crossing is a vanish for the anchor too")
|
||||
|
||||
@@ -118,6 +118,55 @@ struct WriterFixture {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshots
|
||||
|
||||
/// The half of the fixture the **snapshot-comparison** suites need (`BoardDiffTests`,
|
||||
/// `BoardAnnouncerTests`): boards written as files and read back through the real loader.
|
||||
///
|
||||
/// They compare `BoardModel` values, and a hand-assembled model would be assembling something
|
||||
/// `BoardLoader` can never produce — a lane with a malformed `order`, a card whose `document` does
|
||||
/// not match its fields. Writing bytes and loading them is the only way the two snapshots in a diff
|
||||
/// are the two snapshots a reload would actually have compared.
|
||||
extension WriterFixture {
|
||||
|
||||
/// `<root>/index.md`, the one file every board must have.
|
||||
@discardableResult
|
||||
func board(title: String = "Board", body: String = "Board description.") throws -> URL {
|
||||
try item("", "---\nschema: 1\ntitle: \(title)\n---\n\(body)\n")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func lane(_ id: String, order: String, title: String, width: Int? = nil, body: String = "") throws -> URL {
|
||||
let widthLine = width.map { "width: \($0)\n" } ?? ""
|
||||
return try item(id, "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(widthLine)---\n\(body)\n")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func card(
|
||||
_ id: String,
|
||||
in laneID: String,
|
||||
order: String,
|
||||
title: String,
|
||||
body: String = "",
|
||||
modified: String? = nil
|
||||
) throws -> URL {
|
||||
let modifiedLine = modified.map { "modified: \($0)\n" } ?? ""
|
||||
return try item("\(laneID)/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(modifiedLine)---\n\(body)\n")
|
||||
}
|
||||
|
||||
/// A card written straight into `<root>/.trash/` — the materialized trash's shape, for the
|
||||
/// diff rule that says churn in there is not a change to the board.
|
||||
@discardableResult
|
||||
func trashCard(_ id: String, order: String, title: String) throws -> URL {
|
||||
try item(".trash/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\n")
|
||||
}
|
||||
|
||||
/// The board as the loader reads it right now — the value a reload would have landed.
|
||||
func snapshot() throws -> BoardModel {
|
||||
try BoardLoader.load(boardRoot: root).model
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Move/copy identities
|
||||
|
||||
/// Literal UUID-shaped names for the move/copy suites, which need more of them than
|
||||
|
||||
@@ -48,6 +48,8 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is left alone, and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
|
||||
|
||||
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent, and a card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear.
|
||||
|
||||
## Development
|
||||
|
||||
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
|
||||
|
||||
Reference in New Issue
Block a user