Comments, phase 1 — storage, writer primitives, and the undo inventory
The kind: comment field table lands in IntegrityRules (the per-kind hook's first exercise), CommentThread reads one card's thread window-scoped (the board walk stays O(cards)), and CommentWriter gains the five gestures: draft save, post (rename .draft to a fresh UUID, created/modified restamped in the bracket), edit, delete into comments/.trash/, and the purge with its crash-residue memo. Post and delete register move-based undo steps; draft saves, edits, and the purge deliberately register nothing (13's no-capture rule). Copy boundaries strip comments/.trash, carry .draft verbatim, and remint threads; comments graduates to a displacing claimed name, with .draft, .trash, and a comment's attachments claimed one level down. CommentPath classifies changed paths into the 06 verb family for later announcer/composer wiring. One stated narrowing pending a ruling (filed on the findings board): the copy transaction's refuse-whole preflight stays cards-and-lanes — an unstampable copied comment copies verbatim with a log line, because comment defects never refuse. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -349,15 +349,18 @@ struct CardClaimedNameTests {
|
||||
#expect(result.claimedNameSquatters.map(\.found) == [.symlink])
|
||||
}
|
||||
|
||||
/// **`comments` stays tolerated** — the timing principle, stated as the absence of a defect.
|
||||
@Test("A file on a card's comments is not displaced — the name is not load-bearing yet")
|
||||
func fileOnCommentsIsTolerated() throws {
|
||||
/// **`comments` graduated** (2026-07-30, with the comment storage) — the timing principle run
|
||||
/// forwards: the name became load-bearing, so its squatter joined the scheduled class exactly as
|
||||
/// 01 said it would ("a wrong-kind holder is a tolerated stray today and joins the scheduled class
|
||||
/// the day the name becomes load-bearing"). This test used to pin the absence of the defect.
|
||||
@Test("A file on a card's comments is displaced now that the thread consumes the name")
|
||||
func fileOnCommentsIsADefect() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/comments", Data("someday".utf8))
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.claimedNameSquatters.isEmpty)
|
||||
#expect(result.claimedNameSquatters.map(\.name) == ["comments"])
|
||||
#expect(result.looseCardFiles.isEmpty, "a reserved name is not a loose file either")
|
||||
}
|
||||
|
||||
@@ -447,14 +450,14 @@ struct CardClaimedNameTests {
|
||||
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments 2") == Data("two".utf8))
|
||||
}
|
||||
|
||||
/// The table is the only thing to edit when `comments` graduates — pinned so the split is a stated
|
||||
/// rule rather than an accident of the probe's implementation.
|
||||
@Test("The card-level table claims attachments and comments, and displaces only attachments")
|
||||
/// The table is the whole rule — pinned so the level-uniform claim is stated rather than being an
|
||||
/// accident of the probe's implementation. Both card-level names displace since `comments`
|
||||
/// graduated with the comment storage (2026-07-30).
|
||||
@Test("The card-level table claims attachments and comments, and both displace")
|
||||
func theTableStatesTheSplit() {
|
||||
let names = IntegrityRules.claimedCardChildNames
|
||||
#expect(names.map(\.name) == ["attachments", "comments"])
|
||||
#expect(names.allSatisfy { $0.expected == .directory })
|
||||
#expect(names.first { $0.name == "attachments" }?.displacesSquatters == true)
|
||||
#expect(names.first { $0.name == "comments" }?.displacesSquatters == false)
|
||||
#expect(names.allSatisfy { $0.displacesSquatters })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The **read** half of the comment thread — the window-scoped loader, the `kind: comment` field
|
||||
/// table, the claimed names the thread adds, and the path-shape classification the announcer and the
|
||||
/// composer will read (01-storage-format.md § Enhanced schema, storage specified 2026-07-29).
|
||||
///
|
||||
/// Everything here writes raw bytes and reads them back through the real loader, like every other
|
||||
/// storage suite: `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// Literal comment identities. Deliberately spanning cases and versions — the identity predicate is
|
||||
/// shape-only, and a thread must not sort or dedupe by an uppercase folder's ASCII accident.
|
||||
enum CommentIdent {
|
||||
static let one = "aaaaaaaa-0000-4000-8000-000000000001"
|
||||
static let two = "bbbbbbbb-0000-4000-8000-000000000002"
|
||||
static let three = "cccccccc-0000-4000-8000-000000000003"
|
||||
static let upper = "DDDDDDDD-0000-4000-8000-000000000004"
|
||||
static let seven = "eeeeeeee-0000-7000-8000-000000000005"
|
||||
}
|
||||
|
||||
/// A comment's `index.md` with everything a write has to leave alone: an unknown key with an inline
|
||||
/// comment, a reserved tracker key, a self-reported `author`, and a body.
|
||||
func commentText(
|
||||
author: String? = "Ada Lovelace",
|
||||
created: String? = "2026-01-01T09:00:00Z",
|
||||
modified: String? = "2026-01-01T09:00:00Z",
|
||||
remote: String? = nil,
|
||||
body: String = "A comment body — with *markdown*.\n"
|
||||
) -> String {
|
||||
var lines = ["---", "schema: 1"]
|
||||
if let author { lines.append("author: \(author)") }
|
||||
lines.append("project: lanework # agent overlay")
|
||||
if let remote { lines.append("remote: \(remote)") }
|
||||
if let created { lines.append("created: \(created)") }
|
||||
if let modified { lines.append("modified: \(modified)") }
|
||||
lines.append("kind: comment")
|
||||
lines.append("---")
|
||||
return lines.joined(separator: "\n") + "\n" + body
|
||||
}
|
||||
|
||||
/// A one-lane, one-card board — the smallest thing a thread can hang off.
|
||||
@discardableResult
|
||||
func makeCommentBoard(_ fixture: WriterFixture) throws -> String {
|
||||
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"))
|
||||
return "\(Ident.lane1)/\(Ident.card1)"
|
||||
}
|
||||
|
||||
func commentPath(_ id: String, inCard cardPath: String) -> String {
|
||||
"\(cardPath)/comments/\(id)"
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
@Suite("Comments ▸ thread load")
|
||||
struct CommentThreadLoadTests {
|
||||
|
||||
@Test("The thread is the UUID folders under comments/, and nothing else")
|
||||
func loadsPostedCommentsOnly() 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("\(card)/comments/.draft", commentText(created: nil, modified: nil))
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
|
||||
#expect(thread.hasDraft)
|
||||
#expect(thread.strays.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A card with no comments/ answers empty, and that is not a defect")
|
||||
func noThreadIsEmpty() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread == .empty)
|
||||
}
|
||||
|
||||
@Test("Chronology ascending is the order")
|
||||
func sortsByCreatedAscending() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
// Written in an order that neither matches the dates nor the folder names, so neither can
|
||||
// be the thing producing the answer by accident.
|
||||
try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: "2026-03-03T09:00:00Z"))
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: "2026-05-05T09:00:00Z"))
|
||||
try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-01-01T09:00:00Z"))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two, CommentIdent.three, CommentIdent.one])
|
||||
}
|
||||
|
||||
@Test("Ties break by the canonical lowercase folder name")
|
||||
func tiesBreakByCanonicalName() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
// `DDDDDDDD-…` sorts before every lowercase name by raw ASCII and after `cccccccc-…` by the
|
||||
// canonical fold, which is the rule (01 § Ordering — "comparing the canonical lowercase
|
||||
// spelling … never an uppercase folder's ASCII accident").
|
||||
let stamp = "2026-02-02T09:00:00Z"
|
||||
try fixture.item(commentPath(CommentIdent.upper, inCard: card), commentText(created: stamp))
|
||||
try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: stamp))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.three, CommentIdent.upper])
|
||||
}
|
||||
|
||||
@Test("A missing or malformed created sorts after every dated sibling, by name")
|
||||
func undatedSortLast() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: nil))
|
||||
try fixture.item(commentPath(CommentIdent.upper, inCard: card), commentText(created: "[not, a, date]"))
|
||||
// The one dated sibling, and deliberately the *latest* possible name, so a name-only sort
|
||||
// would put it last and a date-first sort puts it first.
|
||||
try fixture.item(commentPath(CommentIdent.seven, inCard: card), commentText(created: "2026-09-09T09:00:00Z"))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.seven, CommentIdent.one, CommentIdent.upper])
|
||||
}
|
||||
|
||||
@Test("A coerce-tier fallback is logged as a defect, and changes nothing else")
|
||||
func coercionsReported() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: "[not, a, date]"))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.count == 1, "the comment still renders — coercion changes no behavior")
|
||||
let coerced = thread.defects.compactMap { defect -> CoercedFrontmatter? in
|
||||
if case let .coercedFrontmatter(work) = defect { work } else { nil }
|
||||
}
|
||||
#expect(coerced.count == 1)
|
||||
#expect(coerced.first?.fields.map(\.key) == [FrontmatterKeys.created])
|
||||
#expect(coerced.first?.path == "\(card)/comments/\(CommentIdent.one)/index.md")
|
||||
}
|
||||
|
||||
@Test("A malformed author is a coerce-tier fallback too, and the field reads unattributed")
|
||||
func authorCoerces() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(author: "[a, b]"))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.first?.author.isMalformed == true)
|
||||
#expect(thread.comments.first?.author.value == nil)
|
||||
let keys = thread.defects.flatMap { defect -> [String] in
|
||||
if case let .coercedFrontmatter(work) = defect { work.fields.map(\.key) } else { [] }
|
||||
}
|
||||
#expect(keys.contains(FrontmatterKeys.author))
|
||||
}
|
||||
|
||||
@Test("Edited is modified differing from created, and nothing else")
|
||||
func editedIndicator() 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-01-01T09:00:00Z", modified: "2026-02-02T09:00:00Z")
|
||||
)
|
||||
try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: nil, modified: nil))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
let edited = Dictionary(uniqueKeysWithValues: thread.comments.map { ($0.id.rawValue, $0.isEdited) })
|
||||
#expect(edited[CommentIdent.one] == false)
|
||||
#expect(edited[CommentIdent.two] == true)
|
||||
#expect(edited[CommentIdent.three] == false, "a missing pair is not evidence of an edit")
|
||||
}
|
||||
|
||||
@Test("Attachments list flat, in Finder order")
|
||||
func attachmentsAreFinderOrdered() 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())
|
||||
try fixture.file("\(path)/attachments/shot 10.png", Data("ten".utf8))
|
||||
try fixture.file("\(path)/attachments/shot 2.png", Data("two".utf8))
|
||||
try fixture.file("\(path)/attachments/nested/deep.png", Data("deep".utf8))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.first?.attachments == ["shot 2.png", "shot 10.png"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Defect tolerance
|
||||
|
||||
@Suite("Comments ▸ defects never refuse")
|
||||
struct CommentDefectToleranceTests {
|
||||
|
||||
/// Every shape that would have failed a *card* load outright, each beside one healthy sibling
|
||||
/// so the claim is "the rest of the thread renders" and not merely "nothing threw".
|
||||
@Test("A broken comment is a stray: skipped, logged, and the thread still renders")
|
||||
func brokenCommentsAreStrays() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
||||
// Unparseable YAML.
|
||||
try fixture.item(commentPath(CommentIdent.two, inCard: card), "---\nschema: 1\n bad: [\n---\nbody\n")
|
||||
// Not UTF-8.
|
||||
try fixture.item(commentPath(CommentIdent.upper, inCard: card), bytes: Data([0xFF, 0xFE, 0x00]))
|
||||
// Two-step-create residue: an identity-shaped folder with no index.md.
|
||||
try FileManager.default.createDirectory(
|
||||
at: fixture.url(commentPath(CommentIdent.seven, inCard: card)),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
// A stray folder that is not identity-shaped at all.
|
||||
try fixture.item("\(card)/comments/notes", commentText())
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
|
||||
#expect(thread.strays.count == 4)
|
||||
#expect(thread.strays.contains { $0.name == "notes" && $0.reason == .notIdentityShaped })
|
||||
#expect(thread.strays.contains { $0.name == CommentIdent.seven && $0.reason == .missingIndex })
|
||||
// Every stray's bytes are still exactly where they were: tolerated is preserved-verbatim.
|
||||
#expect(fixture.exists(commentPath(CommentIdent.two, inCard: card)))
|
||||
#expect(fixture.exists("\(card)/comments/notes"))
|
||||
}
|
||||
|
||||
@Test("A missing schema does not stop a comment's body from being unreadable-tolerated")
|
||||
func schemaIsLenientOnAComment() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), "---\nauthor: Ada\n---\nstill readable\n")
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.count == 1, "a comment's schema is lenient, unlike every level's")
|
||||
#expect(thread.comments.first?.schema.isMissing == true)
|
||||
#expect(thread.comments.first?.body == "still readable\n")
|
||||
}
|
||||
|
||||
@Test("A board carrying a broken comment still loads")
|
||||
func theBoardIsUnaffected() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), "---\nnot: [valid\n---\n")
|
||||
|
||||
let model = try fixture.snapshot()
|
||||
#expect(model.lanes.first?.cards.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The per-kind field table
|
||||
|
||||
@Suite("Comments ▸ the kind: comment field table")
|
||||
struct CommentFieldTableTests {
|
||||
|
||||
@Test("schema is the only required field — never order, never title")
|
||||
func requiredFields() throws {
|
||||
#expect(IntegrityRules.requiresOrder(.comment) == false)
|
||||
|
||||
let withoutOrder = Data("---\nschema: 1\nkind: comment\n---\nbody\n".utf8)
|
||||
#expect(throws: Never.self) {
|
||||
try IntegrityRules.validateIndex(withoutOrder, path: "index.md", kind: .comment, supportedSchema: 1)
|
||||
}
|
||||
let withoutSchema = Data("---\nkind: comment\n---\nbody\n".utf8)
|
||||
#expect(throws: BoardLoadError.self) {
|
||||
try IntegrityRules.validateIndex(withoutSchema, path: "index.md", kind: .comment, supportedSchema: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Position places a comment, and the URL form tells the two trashes apart")
|
||||
func placement() {
|
||||
let uuid = CommentIdent.one
|
||||
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: "comments") == .comment)
|
||||
#expect(IntegrityRules.placement(ofFolderNamed: ".draft", inParentNamed: "comments") == .comment)
|
||||
// The two-name form cannot tell a thread's `.trash` from the board's.
|
||||
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: ".trash") == .insideTrash)
|
||||
|
||||
let root = URL(fileURLWithPath: "/b.kanban")
|
||||
let card = root.appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1)
|
||||
let thread = card.appendingPathComponent("comments")
|
||||
#expect(IntegrityRules.placement(ofFolder: thread.appendingPathComponent(uuid)) == .comment)
|
||||
#expect(
|
||||
IntegrityRules.placement(ofFolder: thread.appendingPathComponent(".trash").appendingPathComponent(uuid))
|
||||
== .comment,
|
||||
"a comment in its thread's trash is still a comment"
|
||||
)
|
||||
#expect(
|
||||
IntegrityRules.placement(ofFolder: root.appendingPathComponent(".trash").appendingPathComponent(uuid))
|
||||
== .insideTrash,
|
||||
"the board's trash is unchanged"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("kind: comment is never a board-trash answer — shape decides there")
|
||||
func trashKindIgnoresComment() {
|
||||
#expect(IntegrityRules.trashKind(kindValue: "comment", hasIdentityShapedChildIndex: false) == .card)
|
||||
#expect(IntegrityRules.trashKind(kindValue: "comment", hasIdentityShapedChildIndex: true) == .lane)
|
||||
}
|
||||
|
||||
@Test("The on-touch backfill stamps kind: comment, never kind: lane")
|
||||
func backfillIsComment() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
// A comment folder is identity-shaped under a non-identity-shaped parent — shape-identical
|
||||
// to a lane, which is exactly the mistake `Placement.comment` exists to prevent.
|
||||
try fixture.item(path, "---\nschema: 1\nauthor: Ada\n---\nbody\n")
|
||||
|
||||
try BoardWriter.editComment(at: fixture.url(path), body: "edited\n", cardTitle: "Fix login")
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText(path))
|
||||
#expect(document.kind.value == "comment")
|
||||
}
|
||||
|
||||
@Test("author survives every app write; modified-by does not")
|
||||
func authorSurvives() 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 Lovelace\nmodified-by: claude\nkind: comment\n---\nbody\n")
|
||||
|
||||
try BoardWriter.editComment(at: fixture.url(path), body: "edited\n", cardTitle: "Fix login")
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText(path))
|
||||
#expect(document.author.value == "Ada Lovelace")
|
||||
#expect(document.modifiedBy.isMissing, "modified-by is overlay; author is content")
|
||||
}
|
||||
|
||||
@Test("author is an ordinary unknown key on every other kind — the Details section still shows it")
|
||||
func authorIsNotSchemaOwnedElsewhere() throws {
|
||||
let document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\nauthor: Ada\n---\nbody\n")
|
||||
#expect(document.unknownFields.map(\.key) == [FrontmatterKeys.author])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Claimed names
|
||||
|
||||
@Suite("Comments ▸ claimed names")
|
||||
struct CommentClaimedNameTests {
|
||||
|
||||
@Test("comments graduated: a squatter at card level is now scheduled work")
|
||||
func commentsDisplacesNow() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.file("\(card)/comments", Data("squatter".utf8))
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
let squatters = result.defects.compactMap { defect -> ClaimedNameSquatter? in
|
||||
if case let .claimedNameSquatted(work) = defect { work } else { nil }
|
||||
}
|
||||
#expect(squatters.count == 1)
|
||||
#expect(squatters.first?.name == "comments")
|
||||
#expect(squatters.first?.found == .file)
|
||||
#expect(squatters.first?.location == .card(path: card))
|
||||
}
|
||||
|
||||
@Test("The thread read reports .draft and .trash squatters, and a comment's own attachments")
|
||||
func threadLevelSquatters() 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())
|
||||
try fixture.file("\(card)/comments/.draft", Data("squatter".utf8))
|
||||
try fixture.file("\(card)/comments/.trash", Data("squatter".utf8))
|
||||
try fixture.file("\(path)/attachments", Data("squatter".utf8))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
let squatters = thread.defects.compactMap { defect -> ClaimedNameSquatter? in
|
||||
if case let .claimedNameSquatted(work) = defect { work } else { nil }
|
||||
}
|
||||
#expect(Set(squatters.map(\.name)) == [".draft", ".trash", "attachments"])
|
||||
#expect(squatters.contains { $0.location == .commentThread(cardPath: card) && $0.name == ".draft" })
|
||||
#expect(squatters.contains { $0.location == .comment(path: path) && $0.name == "attachments" })
|
||||
// The board walk stays out of it — a thread is window-scoped.
|
||||
let boardDefects = try BoardLoader.load(boardRoot: fixture.root).defects
|
||||
#expect(!boardDefects.contains { if case .claimedNameSquatted = $0 { true } else { false } })
|
||||
}
|
||||
|
||||
@Test("A file wearing comments makes the thread empty and the work the whole answer")
|
||||
func squattedThreadFolder() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.file("\(card)/comments", Data("squatter".utf8))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
#expect(thread.comments.isEmpty)
|
||||
#expect(thread.defects.count == 1)
|
||||
}
|
||||
|
||||
@Test("Each location displaces by the Finder ladder, and destroys nothing")
|
||||
func displacementLadder() 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())
|
||||
try fixture.file("\(card)/comments/.draft", Data("draft squatter".utf8))
|
||||
try fixture.file("\(path)/attachments", Data("attachment squatter".utf8))
|
||||
|
||||
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
||||
for defect in thread.defects {
|
||||
guard case let .claimedNameSquatted(squatter) = defect else { continue }
|
||||
let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: fixture.root)
|
||||
#expect(freed == "\(squatter.name) 2")
|
||||
}
|
||||
|
||||
#expect(try fixture.data("\(card)/comments/.draft 2") == Data("draft squatter".utf8))
|
||||
#expect(try fixture.data("\(path)/attachments 2") == Data("attachment squatter".utf8))
|
||||
#expect(!fixture.exists("\(card)/comments/.draft"))
|
||||
#expect(!fixture.exists("\(path)/attachments"))
|
||||
}
|
||||
|
||||
@Test("A displacement that lost its race is success, not an error")
|
||||
func displacementReVerifies() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let squatter = ClaimedNameSquatter(
|
||||
name: ".draft",
|
||||
found: .file,
|
||||
expected: .directory,
|
||||
location: .commentThread(cardPath: card)
|
||||
)
|
||||
#expect(try BoardWriter.displaceClaimedName(squatter, atBoardRoot: fixture.root) == nil)
|
||||
}
|
||||
|
||||
@Test("Two locations sign differently, so one heal's failure is not the other's")
|
||||
func signaturesAreDistinct() {
|
||||
let thread = IntegrityRules.Defect.claimedNameSquatted(ClaimedNameSquatter(
|
||||
name: ".trash", found: .file, expected: .directory, location: .commentThread(cardPath: "l/c")
|
||||
))
|
||||
let root = IntegrityRules.Defect.claimedNameSquatted(ClaimedNameSquatter(
|
||||
name: ".trash", found: .file, expected: .directory
|
||||
))
|
||||
#expect(root.signatures == ["claimed:.trash:file"], "the board root's spelling is unchanged")
|
||||
#expect(thread.signatures == ["claimed:l/c/comments/.trash:file"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Path shape
|
||||
|
||||
@Suite("Comments ▸ path shape")
|
||||
struct CommentPathShapeTests {
|
||||
|
||||
private let lane = Ident.lane1
|
||||
private let card = Ident.card1
|
||||
|
||||
@Test("A posted comment's path names its card and its identity")
|
||||
func classifiesComment() {
|
||||
let path = CommentPath.classify("\(lane)/\(card)/comments/\(CommentIdent.one)/index.md")
|
||||
#expect(path?.cardPath == "\(lane)/\(card)")
|
||||
#expect(path?.kind == .comment(ItemID(rawValue: CommentIdent.one)))
|
||||
#expect(path?.id == ItemID(rawValue: CommentIdent.one))
|
||||
}
|
||||
|
||||
@Test("The folder itself classifies, not only files inside it")
|
||||
func classifiesTheFolder() {
|
||||
#expect(CommentPath.classify("\(lane)/\(card)/comments/\(CommentIdent.one)")?.kind
|
||||
== .comment(ItemID(rawValue: CommentIdent.one)))
|
||||
}
|
||||
|
||||
@Test("The draft is its own shape, and has no identity")
|
||||
func classifiesDraft() {
|
||||
let path = CommentPath.classify("\(lane)/\(card)/comments/.draft/index.md")
|
||||
#expect(path?.kind == .draft)
|
||||
#expect(path?.id == nil)
|
||||
}
|
||||
|
||||
@Test("A deleted comment classifies from the thread's own trash")
|
||||
func classifiesTrashed() {
|
||||
let path = CommentPath.classify("\(lane)/\(card)/comments/.trash/\(CommentIdent.two)/index.md")
|
||||
#expect(path?.kind == .trashed(ItemID(rawValue: CommentIdent.two)))
|
||||
}
|
||||
|
||||
@Test("A trashed card carries its thread, and the shape reads the same")
|
||||
func classifiesUnderTheBoardTrash() {
|
||||
let path = CommentPath.classify(".trash/\(card)/comments/\(CommentIdent.one)/index.md")
|
||||
#expect(path?.cardPath == ".trash/\(card)")
|
||||
#expect(path?.kind == .comment(ItemID(rawValue: CommentIdent.one)))
|
||||
}
|
||||
|
||||
@Test("Everything else is somebody else's to describe")
|
||||
func classifiesNothingElse() {
|
||||
let cases = [
|
||||
"\(lane)/\(card)/index.md",
|
||||
"\(lane)/\(card)/attachments/shot.png",
|
||||
"\(lane)/\(card)/comments",
|
||||
"\(lane)/\(card)/comments/notes/index.md",
|
||||
"\(lane)/\(card)/comments/.trash",
|
||||
"\(lane)/\(card)/comments/.trash/notes",
|
||||
"\(lane)/notacard/comments/\(CommentIdent.one)/index.md",
|
||||
"CLAUDE.md",
|
||||
"",
|
||||
]
|
||||
for path in cases {
|
||||
#expect(CommentPath.classify(path) == nil, "'\(path)' should not classify")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
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("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())
|
||||
|
||||
#expect(HistoryStaleness.isCurrent([.present(fixture.url(path))]))
|
||||
#expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))]))
|
||||
try FileManager.default.removeItem(at: fixture.url(path))
|
||||
#expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))]))
|
||||
}
|
||||
}
|
||||
|
||||
// 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"))
|
||||
}
|
||||
|
||||
@Test("A comment nobody can stamp never refuses the copy — it travels verbatim")
|
||||
func brokenCommentDoesNotRefuseACopy() 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.
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
|
||||
|
||||
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)"
|
||||
let names = try postedNames(fixture, inCard: copied)
|
||||
#expect(names.count == 1, "the copy landed whole")
|
||||
#expect(try fixture.indexText("\(copied)/comments/\(names[0])") == Item.uneditable, "verbatim")
|
||||
}
|
||||
|
||||
@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, order: 1024)
|
||||
|
||||
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)"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user