Files
lanework/KanbanTests/CommentThreadTests.swift
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

531 lines
25 KiB
Swift

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 {
/// A comment carries no `order` and never gains one ("Ordering is chronology, not ranks"), and
/// since 2026-07-31 its `schema` is optional too — it sits below the board root like everything
/// else, so an absent one reads as 1. What still refuses is a schema **newer than this app**,
/// which is the one `schema` rule that is level-blind.
@Test("No field is required — order is meaningless here, schema defaults to 1")
func requiredFields() throws {
for frontmatter in ["schema: 1\nkind: comment", "kind: comment"] {
#expect(throws: Never.self) {
try IntegrityRules.validateIndex(
Data("---\n\(frontmatter)\n---\nbody\n".utf8),
path: "index.md", kind: .comment, supportedSchema: 1
)
}
}
let newer = Data("---\nschema: 99\nkind: comment\n---\nbody\n".utf8)
#expect(throws: BoardLoadError.self) {
try IntegrityRules.validateIndex(newer, 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")
}
}
}