Files
lanework/KanbanTests/CommentAnnouncementTests.swift
rzen 71664dab02 Give card windows their own undo stacks and coarsen the close
Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 19:39:50 -04:00

398 lines
16 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// **What a foreign comment change says out loud** — 10-accessibility.md ▸ Comments ("Foreign comment
/// arrivals announce path-shaped ('New comment on '⟨card⟩'')"), 01-storage-format.md ▸ Enhanced schema
/// (the path shape, and the verb family 06 gains with the feature).
///
/// Three layers, three suites, because the rule is three separate claims stacked:
///
/// 1. **What changed** — `CommentThreadChanges`, a pure comparison of two threads.
/// 2. **Whose it was** — the `EchoLedger`'s comment receipts, read through `CommentPath.classify`;
/// the app's own writes never announce.
/// 3. **What that says** — `BoardAnnouncer.commentSpeech(for:onCard:)` over `AccessibilityPhrases`.
///
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`; `CommentIdent`,
/// `commentText` and `makeCommentBoard` from `CommentThreadTests.swift`.
// MARK: - Fixtures
private func comment(_ id: String, body: String) -> Kanban.Comment {
Kanban.Comment(
id: ItemID(rawValue: id),
schema: .valid(1),
author: .missing,
created: .missing,
modified: .missing,
modifiedBy: .missing,
attachments: [],
document: FrontmatterDocument(body: body)
)
}
private func thread(_ comments: [Kanban.Comment]) -> CommentThread {
CommentThread(comments: comments, strays: [], defects: [], hasDraft: false)
}
// MARK: - What changed
@Suite("Comment announcements ▸ the thread diff")
struct CommentThreadChangesTests {
@Test("An arrival, an edit and a departure, each in its own bucket")
func threeBuckets() {
let old = thread([comment(CommentIdent.one, body: "one"), comment(CommentIdent.two, body: "two")])
let new = thread([comment(CommentIdent.one, body: "one, amended"), comment(CommentIdent.three, body: "three")])
let changes = CommentThreadChanges.between(old, new)
#expect(changes.arrived == [ItemID(rawValue: CommentIdent.three)])
#expect(changes.edited == [ItemID(rawValue: CommentIdent.one)])
#expect(changes.deleted == [ItemID(rawValue: CommentIdent.two)])
}
@Test("An unchanged thread is empty — the overwhelmingly common reload")
func unchangedIsEmpty() {
let same = thread([comment(CommentIdent.one, body: "one")])
#expect(CommentThreadChanges.between(same, same).isEmpty)
}
@Test("An attachment landing on a comment is not a change here — the comparison is index.md")
func attachmentsAreNotEdits() {
let before = comment(CommentIdent.one, body: "one")
let after = Kanban.Comment(
id: before.id,
schema: before.schema,
author: before.author,
created: before.created,
modified: before.modified,
modifiedBy: before.modifiedBy,
attachments: ["shot.png"],
document: before.document
)
#expect(CommentThreadChanges.between(thread([before]), thread([after])).isEmpty)
}
@Test("Vouched changes are removed one at a time, never all-or-nothing")
func exclusionIsPerComment() {
let changes = CommentThreadChanges(
arrived: [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.two)],
edited: [ItemID(rawValue: CommentIdent.three)]
)
// The app posted one of the two arrivals; an agent filed the other in the same window.
let foreign = changes.excluding([ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.three)])
#expect(foreign.arrived == [ItemID(rawValue: CommentIdent.two)])
#expect(foreign.edited.isEmpty)
}
}
// MARK: - Whose it was
@MainActor
@Suite("Comment announcements ▸ the ledger's comment receipts")
struct CommentReceiptTests {
@Test("A posted comment is vouched for, and the receipts are retired on the way out")
func postIsVouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
store.saveCommentDraft(inCard: cardID, body: "A remark.")
let posted = try #require(store.postComment(inCard: cardID))
#expect(store.vouchedComments(inCard: cardID) == [posted])
// One write, one echo: a second reload finds nothing vouching for the same comment.
#expect(store.vouchedComments(inCard: cardID).isEmpty)
#expect(fixture.exists("\(card)/comments/\(posted.rawValue)"))
}
@Test("An inline edit's save is vouched for — the debounce writes without a re-read")
func editIsVouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "before\n"))
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
#expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "after"))
#expect(store.vouchedComments(inCard: cardID) == [ItemID(rawValue: CommentIdent.one)])
}
@Test("A delete is vouched for at both ends of its move — the thread's and the trash's")
func deleteIsVouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "doomed\n"))
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
#expect(store.vouchedComments(inCard: cardID) == [ItemID(rawValue: CommentIdent.one)])
#expect(store.echoes.outstandingReceipts == 0)
}
@Test("A draft save vouches for nothing in the thread — a draft is not in it")
func draftVouchesForNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
store.saveCommentDraft(inCard: cardID, body: "half a thought")
#expect(store.vouchedComments(inCard: cardID).isEmpty)
// Retired all the same: a receipt nothing ever collects is a receipt that outlives its write.
#expect(store.echoes.outstandingReceipts == 0)
}
@Test("A comment receipt never makes its card's own edit look foreign")
func commentReceiptsDoNotPoisonTheCard() throws {
// The regression this guards: a card's footprint used to resolve *every* receipt under its
// folder against its attachment listing, so a comment's `index.md` — which no snapshot can
// observe — read as absent, failed its receipt, and classified the card foreign. The user's
// own next title or body edit would then have been announced as somebody else's.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let cardFolder = fixture.url(card)
let old = try BoardLoader.load(boardRoot: fixture.root).model
try BoardWriter.writeBody(inItemFolder: cardFolder, body: "An amended body.")
let new = try BoardLoader.load(boardRoot: fixture.root).model
let card1 = try #require(new.lanes.first?.cards.first)
let ledger = EchoLedger()
ledger.recordWrite(
atPath: EchoLedger.key(cardFolder.appendingPathComponent(BoardLoader.indexFileName)),
hash: EchoLedger.hash(of: card1.document.serialized())
)
ledger.recordWrite(
atPath: EchoLedger.key(
CommentThread
.commentFolder(ItemID(rawValue: CommentIdent.one), inCard: cardFolder)
.appendingPathComponent(BoardLoader.indexFileName)
),
hash: "a hash no snapshot can ever produce"
)
let diff = BoardDiff.between(old, new, includingTrash: false)
let verdicts = ledger.verdicts(from: old, to: new, diff: diff, includingTrash: false)
#expect(verdicts.foreignItems.isEmpty)
#expect(!verdicts.foreign.boardChanged)
// And the comment receipt is still there for the card window to collect.
#expect(ledger.outstandingReceipts == 1)
}
}
// MARK: - What it says
@Suite("Comment announcements ▸ the sentence")
struct CommentSpeechTests {
private let card = "Fix login"
@Test("An arrival is 10's own sentence")
func arrivalSpeaks() {
let changes = CommentThreadChanges(arrived: [ItemID(rawValue: CommentIdent.one)])
#expect(BoardAnnouncer.commentSpeech(for: changes, onCard: card) == "New comment on 'Fix login'")
}
@Test("Edits and deletions speak in 06's verb family")
func theFamilySpeaks() {
#expect(
BoardAnnouncer.commentSpeech(
for: CommentThreadChanges(edited: [ItemID(rawValue: CommentIdent.one)]),
onCard: card
) == "Edit comment on 'Fix login'"
)
#expect(
BoardAnnouncer.commentSpeech(
for: CommentThreadChanges(deleted: [ItemID(rawValue: CommentIdent.one)]),
onCard: card
) == "Delete comment on 'Fix login'"
)
}
@Test("Counts fold, like every other count in the app")
func countsFold() {
let two = [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.two)]
#expect(
BoardAnnouncer.commentSpeech(for: CommentThreadChanges(arrived: two), onCard: card)
== "2 new comments on 'Fix login'"
)
#expect(
BoardAnnouncer.commentSpeech(for: CommentThreadChanges(edited: two), onCard: card)
== "2 comments edited on 'Fix login'"
)
#expect(
BoardAnnouncer.commentSpeech(for: CommentThreadChanges(deleted: two), onCard: card)
== "2 comments deleted on 'Fix login'"
)
}
@Test("Arrivals lead — one polite sentence, chosen by precedence rather than concatenated")
func arrivalsLead() {
let changes = CommentThreadChanges(
arrived: [ItemID(rawValue: CommentIdent.one)],
edited: [ItemID(rawValue: CommentIdent.two)],
deleted: [ItemID(rawValue: CommentIdent.three)]
)
#expect(BoardAnnouncer.commentSpeech(for: changes, onCard: card) == "New comment on 'Fix login'")
}
@Test("An untitled card wears the placeholder the board wears")
func untitledCard() {
let changes = CommentThreadChanges(arrived: [ItemID(rawValue: CommentIdent.one)])
#expect(BoardAnnouncer.commentSpeech(for: changes, onCard: nil) == "New comment on 'Untitled'")
}
@Test("Nothing changed is silence")
func nothingIsSilence() {
#expect(BoardAnnouncer.commentSpeech(for: CommentThreadChanges(), onCard: card) == nil)
}
}
// MARK: - The pane's own voice
@MainActor
@Suite("Comment announcements ▸ the pane")
struct CommentPaneAnnouncementTests {
/// A pane wired to a real store on a real board — the only way the receipts half is honest, since
/// what silences an echo is a write having actually happened.
///
/// The store rides on the recorder rather than being returned beside it, and that is not tidiness:
/// `CardWindowHost.configureComments` captures the store **weakly** (a save landing after the board
/// window has gone must write nothing), so a test that let it go out of scope would be driving a
/// pane whose every seam answers "vanished" — which looks exactly like a bug in the pane.
private func makePane(_ fixture: WriterFixture) throws -> (CardComments, Spoken) {
let store = try BoardStore(rootURL: fixture.root)
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
comments.cardTitle = "Fix login"
CardWindowHost.configureComments(
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
)
let spoken = Spoken(store: store)
comments.announce = { spoken.record($0) }
return (comments, spoken)
}
/// What the pane said, and the store it said it about — held here so the weak seams stay wired.
@MainActor
final class Spoken {
let store: BoardStore
private(set) var phrases: [String] = []
init(store: BoardStore) {
self.store = store
}
func record(_ phrase: String?) {
guard let phrase else { return }
phrases.append(phrase)
}
}
@Test("Opening a card with a thread announces nothing — the window is not a change")
func openingIsSilent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "Already here.\n"))
let (comments, spoken) = try makePane(fixture)
comments.open()
#expect(comments.thread.comments.count == 1)
#expect(spoken.phrases.isEmpty)
}
@Test("A foreign arrival speaks path-shaped, naming the card")
func foreignArrivalSpeaks() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let (comments, spoken) = try makePane(fixture)
comments.open()
try fixture.item("\(card)/comments/\(CommentIdent.two)", commentText(body: "An agent's remark.\n"))
comments.reload()
#expect(spoken.phrases == ["New comment on 'Fix login'"])
}
@Test("Our own post is silent — the receipt vouches for it")
func ownPostIsSilent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let (comments, spoken) = try makePane(fixture)
comments.open()
comments.composer.edited("A remark of our own.")
comments.composer.flush()
#expect(comments.composer.postNow() != nil)
comments.reload()
#expect(comments.thread.comments.count == 1)
#expect(spoken.phrases.isEmpty)
}
@Test("Our own inline edit is silent, even though nothing re-read the thread in between")
func ownEditIsSilent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "before\n"))
let (comments, spoken) = try makePane(fixture)
let store = spoken.store
comments.open()
// The debounced save the inline session makes, without a reload of its own — so the *next*
// reload is the first to see it, which is exactly the case the ledger has to cover.
#expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1), body: "after"))
comments.reload()
#expect(comments.thread.comments.first?.body == "after")
#expect(spoken.phrases.isEmpty)
}
@Test("Our own delete is silent")
func ownDeleteIsSilent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "doomed\n"))
let (comments, spoken) = try makePane(fixture)
comments.open()
comments.delete(ItemID(rawValue: CommentIdent.one))
#expect(comments.thread.comments.isEmpty)
#expect(spoken.phrases.isEmpty)
}
@Test("A foreign arrival alongside our own post announces the half nobody vouched for")
func mixedReloadSpeaksTheForeignHalf() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let (comments, spoken) = try makePane(fixture)
let store = spoken.store
comments.open()
store.saveCommentDraft(inCard: ItemID(rawValue: Ident.card1), body: "Ours.")
#expect(store.postComment(inCard: ItemID(rawValue: Ident.card1)) != nil)
try fixture.item("\(card)/comments/\(CommentIdent.three)", commentText(body: "Theirs.\n"))
comments.reload()
#expect(comments.thread.comments.count == 2)
#expect(spoken.phrases == ["New comment on 'Fix login'"])
}
}