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") } } }