Files
lanework/KanbanTests/CommentWriteTests.swift
T
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

841 lines
39 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// The **write** half of the comment thread: the five Writer primitives, the two move-based inverses
/// registered at the store boundary, and the copy-boundary matrix (01-storage-format.md § Enhanced
/// schema; 05-card-window.md ▸ The comments column; 13-native-undo.md).
///
/// Assertions are against the **bytes on disk**, like every other write suite here — a comment that
/// is merely equivalent in a model is not the claim. `WriterFixture`, `Ident` and `Item` come from
/// `WriterTestSupport.swift`; `CommentIdent`, `commentText` and `makeCommentBoard` from
/// `CommentThreadTests.swift`.
// MARK: - Helpers
private let cardTitle = "Fix login"
private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument {
try FrontmatterDocument.parse(fixture.indexText(relativePath))
}
/// The one comment folder in a thread, as a path — the posted identity is minted, so a test that
/// posts has to find it rather than name it.
private func postedNames(_ fixture: WriterFixture, inCard card: String) throws -> [String] {
try fixture.entryNames("\(card)/comments")
.filter { IntegrityRules.isIdentityShaped($0) }
.sorted()
}
@MainActor
private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) {
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
return (store, history)
}
// MARK: - The draft
@Suite("Comments ▸ draft lifecycle")
struct CommentDraftTests {
@Test("The first save creates .draft with the comment schema, no title and no order")
func createStampsTheSchema() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let outcome = try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: "Half a thought", cardTitle: cardTitle
)
#expect(outcome == .created)
let draft = "\(card)/comments/.draft"
let text = try fixture.indexText(draft)
let parsed = try FrontmatterDocument.parse(text)
#expect(parsed.schema.value == 1)
#expect(parsed.kind.value == "comment")
#expect(parsed.author.value == NSFullUserName())
#expect(parsed.created.value != nil)
// One `Date` for both stamps, so a draft never reads as edited before it is posted.
#expect(parsed.created.value == parsed.modified.value)
#expect(parsed.title.isMissing, "a comment has no title")
#expect(parsed.order.isMissing, "a comment has no order")
#expect(parsed.body == "Half a thought")
#expect(text.hasPrefix("---\nschema: 1\n"))
}
@Test("An update replaces the body span and stamps modified; author and created stay")
func updateWritesTheBody() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let draft = "\(card)/comments/.draft"
try fixture.item(draft, commentText())
let outcome = try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: "Second thought\n", cardTitle: cardTitle
)
#expect(outcome == .updated)
let parsed = try document(fixture, draft)
#expect(parsed.body == "Second thought\n")
#expect(parsed.author.value == "Ada Lovelace")
#expect(parsed.created.value == parsed.created.value)
#expect(parsed.modified.value != parsed.created.value)
// The unknown key, its inline comment and its position are untouched.
#expect(try fixture.indexText(draft).contains("project: lanework # agent overlay"))
}
@Test("Identical bytes write nothing at all — the ~30 s tick must not stamp")
func unchangedWritesNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let draft = "\(card)/comments/.draft"
try fixture.item(draft, commentText(body: "Same\n"))
let before = try fixture.indexData(draft)
let stamp = try FileManager.default
.attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date
let outcome = try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: "Same\n", cardTitle: cardTitle
)
#expect(outcome == .unchanged)
#expect(try fixture.indexData(draft) == before)
let after = try FileManager.default
.attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date
#expect(after == stamp)
}
@Test("A draft emptied of text with no attachments deletes its folder — never litter")
func emptiedDraftIsDeleted() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText(body: "typed\n"))
let outcome = try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: " \n\n", cardTitle: cardTitle
)
#expect(outcome == .deleted)
#expect(!fixture.exists("\(card)/comments/.draft"))
#expect(fixture.exists("\(card)/comments"), "the container stays — the next draft mints into it")
}
@Test("A draft emptied of text but holding attachments survives")
func emptiedDraftWithAttachmentsSurvives() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let draft = "\(card)/comments/.draft"
try fixture.item(draft, commentText(body: "typed\n"))
try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8))
let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle)
#expect(outcome == .updated)
#expect(try document(fixture, draft).body == "")
#expect(fixture.exists("\(draft)/attachments/shot.png"))
}
@Test("An empty save against no draft creates nothing")
func emptySaveCreatesNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle)
#expect(outcome == .unchanged)
#expect(!fixture.exists("\(card)/comments"))
}
@Test("An interrupted create — a draft folder with no index.md — is filled in, not refused")
func indexlessDraftIsFilledIn() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try FileManager.default.createDirectory(
at: fixture.url("\(card)/comments/.draft"), withIntermediateDirectories: true
)
#expect(try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: "recovered\n", cardTitle: cardTitle
) == .created)
#expect(try document(fixture, "\(card)/comments/.draft").body == "recovered\n")
}
@Test("A draft is not a card write: only a card folder takes one")
func refusesNonCards() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let failure = writeFailure {
try BoardWriter.saveCommentDraft(inCard: fixture.url(Ident.lane1), body: "x", cardTitle: nil)
}
#expect(failure?.operation == .saveCommentDraft(title: nil))
}
}
// MARK: - Posting
@Suite("Comments ▸ post")
struct CommentPostTests {
@Test("Posting renames the draft to a fresh lowercase identity and restamps both dates")
func postRenamesAndRestamps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let draft = "\(card)/comments/.draft"
try fixture.item(draft, commentText(created: "2020-01-01T09:00:00Z", modified: "2020-01-01T09:00:00Z"))
try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8))
let draftBody = try document(fixture, draft).body
let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
#expect(!fixture.exists(draft))
#expect(posted.id.rawValue == posted.id.rawValue.lowercased())
#expect(IntegrityRules.isIdentityShaped(posted.id.rawValue))
#expect(try postedNames(fixture, inCard: card) == [posted.id.rawValue])
let path = "\(card)/comments/\(posted.id.rawValue)"
let parsed = try document(fixture, path)
#expect(parsed.created.value == parsed.modified.value, "post time is one instant, not two")
#expect(parsed.created.value.map { abs($0.timeIntervalSince(posted.posted)) < 1 } == true)
#expect(parsed.created.value != nil && parsed.created.value! > Date(timeIntervalSince1970: 1_700_000_000))
// Everything else is the draft's own bytes: the body, the author, the unknown key and its
// inline comment, and the attachment that rode the rename.
#expect(parsed.body == draftBody)
#expect(parsed.author.value == "Ada Lovelace")
#expect(try fixture.indexText(path).contains("project: lanework # agent overlay"))
#expect(fixture.exists("\(path)/attachments/shot.png"))
}
@Test("A posted comment joins the thread; the draft no longer does")
func postedCommentIsInTheThread() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id) == [posted.id])
#expect(!thread.hasDraft)
}
@Test("Posting nothing refuses, naming the card")
func postWithoutADraftRefuses() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let failure = writeFailure { _ = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) }
#expect(failure?.operation == .postComment(title: cardTitle))
}
@Test("A second post mints a second identity beside the first")
func twoPostsAreTwoComments() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText(body: "one\n"))
let first = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
try fixture.item("\(card)/comments/.draft", commentText(body: "two\n"))
let second = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
#expect(first.id != second.id)
#expect(try postedNames(fixture, inCard: card).count == 2)
}
}
// MARK: - Editing
@Suite("Comments ▸ edit")
struct CommentEditTests {
@Test("An edit writes the body and stamps modified — '· edited' falls out of the pair")
func editStampsModified() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
#expect(try BoardWriter.editComment(at: fixture.url(path), body: "revised\n", cardTitle: cardTitle))
let parsed = try document(fixture, path)
#expect(parsed.body == "revised\n")
#expect(parsed.created.value != parsed.modified.value)
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.first?.isEdited == true)
}
@Test("An unchanged body writes nothing")
func unchangedEditWritesNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText(body: "same\n"))
let before = try fixture.indexData(path)
#expect(try BoardWriter.editComment(at: fixture.url(path), body: "same\n", cardTitle: cardTitle) == false)
#expect(try fixture.indexData(path) == before)
}
@Test("Only a posted comment is editable — never the draft, never a trashed one")
func refusesTheTwoDotFolders() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
for path in ["\(card)/comments/.draft", "\(card)/comments/.trash/\(CommentIdent.one)"] {
let failure = writeFailure {
_ = try BoardWriter.editComment(at: fixture.url(path), body: "x", cardTitle: cardTitle)
}
#expect(failure?.operation == .editComment(title: cardTitle))
}
}
}
// MARK: - Delete, restore, purge
@Suite("Comments ▸ delete and purge")
struct CommentDeleteTests {
@Test("A delete is a move into comments/.trash that stamps and clears modified-by")
func deleteMovesAndStamps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, "---\nschema: 1\nauthor: Ada\nmodified-by: claude\ncreated: 2026-01-01T09:00:00Z\nmodified: 2026-01-01T09:00:00Z\nkind: comment\n---\nbody\n")
let id = try BoardWriter.deleteComment(at: fixture.url(path), cardTitle: cardTitle)
#expect(id.rawValue == CommentIdent.one)
#expect(!fixture.exists(path))
let trashed = "\(card)/comments/.trash/\(CommentIdent.one)"
let parsed = try document(fixture, trashed)
#expect(parsed.modified.value != parsed.created.value, "a container change stamps")
#expect(parsed.modifiedBy.isMissing)
#expect(parsed.author.value == "Ada", "author is content and survives even this")
#expect(parsed.body == "body\n")
}
@Test("A deleted comment leaves the thread")
func deletedIsExcluded() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-02-02T09:00:00Z"))
try BoardWriter.deleteComment(at: fixture.url(commentPath(CommentIdent.one, inCard: card)), cardTitle: cardTitle)
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two])
}
@Test("Restoring is the ordinary move back out, and stamps again")
func restoreMovesBack() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
try BoardWriter.restoreComment(
ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card), cardTitle: cardTitle
)
#expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)))
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
#expect(try document(fixture, commentPath(CommentIdent.one, inCard: card)).body
== "A comment body — with *markdown*.\n")
}
@Test("The purge removes the entries, keeps strays, and leaves the container")
func purgeRemovesEntriesOnly() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
try fixture.file("\(card)/comments/.trash/notes.txt", Data("mine".utf8))
let purged = try BoardWriter.purgeCommentTrash(inCard: fixture.url(card))
#expect(Set(purged.map(\.rawValue)) == [CommentIdent.one, CommentIdent.two])
#expect(fixture.exists("\(card)/comments/.trash"))
#expect(try fixture.data("\(card)/comments/.trash/notes.txt") == Data("mine".utf8))
}
@Test("A card with no thread trash purges nothing")
func purgeIsTotal() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
#expect(try BoardWriter.purgeCommentTrash(inCard: fixture.url(card)).isEmpty)
}
}
// MARK: - The crash-residue memo
@MainActor
@Suite("Comments ▸ crash residue")
struct CommentResidueTests {
@Test("Residue left by a crashed session sweeps at the next open, and the memo clears")
func residueSweeps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
let (store, _) = try makeStore(fixture)
store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1))
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
#expect(store.heals.memo(for: .commentTrashResidue) == nil, "cleared on success")
}
@Test("Content a live step still backs is not residue — the sweep asks the stack first")
func aLiveStepsBackingIsNotResidue() throws {
// 13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31: "`comments/.trash/`
// content referenced by a live coarse step on the board stack is a step's backing, not
// residue — the open-time sweep consults the stack and skips owned content". The gate is over
// *steps*, not over the coarse one by name, so the shortest way to hold a live step is the
// window-less delete (`deleteComment`'s `nil` window, which is the board's own stack).
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
let (store, history) = try makeStore(fixture)
let cardID = ItemID(rawValue: Ident.card1)
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
store.sweepCommentTrashResidue(inCard: cardID)
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
"the delete step's undo is the move back out — this is its backing")
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"),
"and the entry no step names swept as before")
history.undo()
#expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)), "so ⌘Z still has something to restore")
}
@Test("An open with nothing to sweep rests — no bracket, no memo")
func cleanOpenRests() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let (store, _) = try makeStore(fixture)
store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1))
#expect(store.heals.memo(for: .commentTrashResidue) == nil)
}
@Test("The close purge and the residue sweep converge on the same disk state")
func purgeAndSweepAgree() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
let (store, _) = try makeStore(fixture)
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
}
// MARK: - Undo
@MainActor
@Suite("Comments ▸ undo")
struct CommentUndoTests {
private func board() throws -> (fixture: WriterFixture, card: String) {
let fixture = try WriterFixture()
let card = try makeCommentBoard(fixture)
return (fixture, card)
}
@Test("Posting registers one step, named for the 06 verb family")
func postRegistersOneStep() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item("\(card)/comments/.draft", commentText())
let (store, history) = try makeStore(fixture)
_ = store.postComment(inCard: ItemID(rawValue: Ident.card1))
#expect(history.canUndo)
#expect(history.undoActionName == "Comment")
}
@Test("Undoing a post renames back to .draft; redo replays the same identity and instant")
func postRoundTrip() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item("\(card)/comments/.draft", commentText(body: "drafted\n"))
let (store, history) = try makeStore(fixture)
let posted = store.postComment(inCard: ItemID(rawValue: Ident.card1))
let id = try #require(posted)
let postedText = try fixture.indexText("\(card)/comments/\(id.rawValue)")
history.undo()
#expect(!fixture.exists("\(card)/comments/\(id.rawValue)"))
#expect(fixture.exists("\(card)/comments/.draft"))
// A rename stamps nothing: the un-posted draft carries the post's own bytes, untouched.
#expect(try fixture.indexText("\(card)/comments/.draft") == postedText)
history.redo()
#expect(try postedNames(fixture, inCard: card) == [id.rawValue], "the same identity, not a fresh mint")
#expect(try fixture.indexText("\(card)/comments/\(id.rawValue)") == postedText, "the same instant, restamped")
}
@Test("A .draft typed since makes the post's undo stale — it skips, it never clobbers")
func postUndoSkipsWhenADraftIsBack() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item("\(card)/comments/.draft", commentText(body: "first\n"))
let (store, history) = try makeStore(fixture)
let id = try #require(store.postComment(inCard: ItemID(rawValue: Ident.card1)))
// A new draft, the way the composer would leave one.
try fixture.item("\(card)/comments/.draft", commentText(body: "second\n"))
let untouched = try fixture.indexText("\(card)/comments/.draft")
history.undo()
#expect(try fixture.indexText("\(card)/comments/.draft") == untouched, "the new draft is not overwritten")
#expect(fixture.exists("\(card)/comments/\(id.rawValue)"), "and the posted comment stays posted")
#expect(!history.canUndo, "the stale step was popped")
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
}
@Test("Deleting registers one step; undo moves it back and redo moves it in")
func deleteRoundTrip() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
let (store, history) = try makeStore(fixture)
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
#expect(history.undoActionName == "Delete Comment")
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
history.undo()
#expect(fixture.exists(path))
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
#expect(try document(fixture, path).body == "A comment body — with *markdown*.\n")
history.redo()
#expect(!fixture.exists(path))
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
}
@Test("A purge leaves the delete step stale — it skips with a banner, resurrecting nothing")
func purgeMakesDeleteStepsStale() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
let (store, history) = try makeStore(fixture)
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
#expect(history.canUndo, "invalidation is lazy — the stack still looks full")
history.undo()
#expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)))
#expect(!history.canUndo)
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
}
@Test("The draft save, the inline edit and the purge register nothing")
func theThreeSilentOperations() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
let (store, history) = try makeStore(fixture)
let cardID = ItemID(rawValue: Ident.card1)
store.saveCommentDraft(inCard: cardID, body: "typing\n")
#expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "revised\n"))
store.purgeCommentTrash(inCard: cardID)
#expect(!history.canUndo, "no byte capture in any tier — 13's rule")
}
@Test("Comment expectations validate disk, which is why comments need no snapshot")
func expectationsReadDisk() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
// Path anchors resolve to themselves — the resolver a caller with no board hands in
// (`HistoryAnchor.literalPath`); the card-anchored halves are `CardSessionUndoTests`'.
#expect(HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
#expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))], resolvedBy: \.literalPath))
try FileManager.default.removeItem(at: fixture.url(path))
#expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
}
}
// MARK: - Copy boundaries
@Suite("Comments ▸ copy boundaries")
struct CommentCopyTests {
/// A card with a full thread: one posted comment carrying a tracker key and an attachment, a
/// draft, and one comment sitting in the thread's trash.
private func threadedCard(_ fixture: WriterFixture, in lane: String, card: String) throws -> String {
let path = "\(lane)/\(card)"
try fixture.item(path, Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(path)/comments/\(CommentIdent.one)", commentText(remote: "gitea#42"))
try fixture.file("\(path)/comments/\(CommentIdent.one)/attachments/shot.png", Data("png".utf8))
try fixture.item("\(path)/comments/.draft", commentText(body: "unposted\n"))
try fixture.item("\(path)/comments/.trash/\(CommentIdent.two)", commentText())
return path
}
@Test("An item-level copy carries the thread, remints it, severs remote, and strips the trash")
func itemCopyMatrix() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
let copy = try BoardWriter.copyItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2),
order: 1024,
stamps: .fork
)
let copied = "\(Ident.lane2)/\(copy.rawValue)"
// The thread came, reminted.
let names = try postedNames(fixture, inCard: copied)
#expect(names.count == 1)
#expect(names[0] != CommentIdent.one, "comment folders remint like every copied folder")
#expect(names[0] == names[0].lowercased())
// The contract applied at comment depth.
let parsed = try document(fixture, "\(copied)/comments/\(names[0])")
#expect(parsed.value(for: FrontmatterKeys.remote) == nil, "the tracker claim is severed")
#expect(parsed.author.value == "Ada Lovelace")
#expect(parsed.created.value != nil, ".fork keeps created")
#expect(fixture.exists("\(copied)/comments/\(names[0])/attachments/shot.png"))
// The draft carries, fork-lossless; the trash does not travel.
#expect(try document(fixture, "\(copied)/comments/.draft").body == "unposted\n")
#expect(!fixture.exists("\(copied)/comments/.trash"))
// The source is untouched, thread trash included.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.two)"))
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)"))
}
@Test("A copied lane's cards' threads are reminted too — arbitrary depth")
func laneCopyReachesCommentDepth() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
let copy = try BoardWriter.copyItem(
at: fixture.url(Ident.lane1), toParent: fixture.root, order: 4096, stamps: .fork
)
let cards = try fixture.entryNames(copy.rawValue).filter { IntegrityRules.isIdentityShaped($0) }
#expect(cards.count == 1)
let copiedCard = "\(copy.rawValue)/\(cards[0])"
let comments = try postedNames(fixture, inCard: copiedCard)
#expect(comments.count == 1)
#expect(comments[0] != CommentIdent.one)
#expect(try document(fixture, "\(copiedCard)/comments/\(comments[0])").value(for: FrontmatterKeys.remote) == nil)
#expect(!fixture.exists("\(copiedCard)/comments/.trash"))
}
/// **The preflight reaches comment depth** (01-storage-format.md § Identity lifecycle and
/// § Enhanced schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out this suite used to
/// pin): "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card
/// or lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not
/// a load".
///
/// The board still loads with this comment on it — that is the other test in this file — and it
/// is the gesture that refuses. Whole, and with nothing materialized: a partial copy is the one
/// outcome the transaction rule exists to prevent.
@Test("A comment nobody can stamp refuses the whole copy, and nothing is materialized")
func brokenCommentRefusesACopy() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
// Readable, uneditable — the shape that refuses a copy at card level, now at comment depth.
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
var thrown: BoardWriteError?
do {
_ = try BoardWriter.copyItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2),
order: 1024,
stamps: .fork
)
} catch {
thrown = error
}
let error = try #require(thrown)
if case .uneditableFrontmatter = error.reason {} else {
Issue.record("expected an uneditable-frontmatter refusal, got \(error.reason)")
}
#expect(error.path.hasSuffix("comments/\(CommentIdent.one)/index.md"),
"the path points at the annotation, which is where the fix is")
// Nothing landed, and the source is untouched.
#expect(try fixture.entryNames(Ident.lane2).filter { IntegrityRules.isIdentityShaped($0) }.isEmpty)
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)")
== Item.uneditable)
}
/// The other side of "load-scoped": the very same board **loads**, and its card window's thread
/// read tolerates the annotation. Only the copy gesture refuses.
@Test("The same broken comment never refuses the board")
func brokenCommentNeverRefusesTheBoard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card1])
}
@Test("Template instantiation is born-today at comment depth too")
func instantiationRestampsComments() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
let copy = try BoardWriter.copyItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2),
order: 1024,
stamps: .born
)
let copied = "\(Ident.lane2)/\(copy.rawValue)"
let names = try postedNames(fixture, inCard: copied)
let created = try #require(document(fixture, "\(copied)/comments/\(names[0])").created.value)
#expect(created.timeIntervalSinceNow > -60, ".born restamps created")
}
@Test("A whole-board fork carries the thread verbatim — but never its trash")
func wholeBoardForkCarriesVerbatim() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
let destination = fixture.root.deletingLastPathComponent()
.appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: destination) }
try BoardTreeCopy.createDirectory(at: destination)
try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false })
let thread = destination
.appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1)
.appendingPathComponent("comments")
// GUIDs kept, tracker keys kept — a fork is a fork.
let forked = thread.appendingPathComponent(CommentIdent.one).appendingPathComponent("index.md")
#expect(FileManager.default.fileExists(atPath: forked.path))
let text = try String(decoding: Data(contentsOf: forked), as: UTF8.self)
#expect(text.contains("remote: gitea#42"))
// The draft too.
#expect(FileManager.default.fileExists(
atPath: thread.appendingPathComponent(".draft").appendingPathComponent("index.md").path
))
// The thread trash, never.
#expect(!FileManager.default.fileExists(atPath: thread.appendingPathComponent(".trash").path))
}
@Test("The board's own .trash is untouched by the comment strip")
func theBoardTrashIsNotTheThreadTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Trashed"))
let destination = fixture.root.deletingLastPathComponent()
.appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: destination) }
try BoardTreeCopy.createDirectory(at: destination)
try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false })
#expect(FileManager.default.fileExists(
atPath: destination.appendingPathComponent(".trash/\(Ident.card4)/index.md").path
))
}
@Test("A trashed card carries its thread — and the trash interplay costs nothing")
func trashedCardsCarryTheirThread() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root)
let trashedCard = ".trash/\(Ident.card1)"
#expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)"))
#expect(fixture.exists("\(trashedCard)/comments/.draft"))
#expect(fixture.exists("\(trashedCard)/comments/.trash/\(CommentIdent.two)"), "a move carries everything")
let thread = CommentThread.load(inCard: fixture.url(trashedCard), path: trashedCard)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
// Restoring is the ordinary move back, thread intact.
_ = try BoardWriter.moveItem(
at: fixture.url(trashedCard),
toParent: fixture.url(Ident.lane1),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 1024
)
#expect(fixture.exists("\(card)/comments/\(CommentIdent.one)"))
}
@Test("The purge takes the whole card, thread and all")
func purgeTakesTheThread() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)")
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card1)"), inBoard: fixture.root)
#expect(!fixture.exists(".trash/\(Ident.card1)"))
}
}