import Foundation import Testing @testable import Kanban /// **Comment attachments author in-window** (05-card-window.md ▸ The comments column, ruled /// 2026-07-29): a file dropped in the composer lands in the draft's `attachments/`, one dropped in an /// inline edit session lands in that comment's, and an authoring chip's Remove moves the file to the /// *system* Trash. /// /// This suite is the **write** half — the two primitives and the shape guard that decides which /// folders are authoring surfaces at all — plus the draft *read* the composer restores from. The /// hover rule that chooses between them is pure and lives in `CardCommentsLayoutTests`; the pane's /// wiring is `CardCommentsTests`'. /// /// Assertions are against the bytes on disk, `CommentWriteTests`' rule. private let cardTitle = "Fix login" // MARK: - Reading the draft @Suite("Comments ▸ reading the draft") struct CommentDraftReadTests { @Test("The composer's restore is the read: body and chips, straight off the folder") func loadDraftReadsBodyAndChips() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText(body: "half a thought\n")) try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8)) let draft = try #require(CommentThread.loadDraft(inCard: fixture.url(card))) #expect(draft.body == "half a thought\n") #expect(draft.attachments == ["shot.png"]) #expect(!draft.isEmpty) } @Test("A card with no draft has nothing to restore") func noDraftIsNil() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) #expect(CommentThread.loadDraft(inCard: fixture.url(card)) == nil) } @Test("A draft folder with no readable index.md still reports its files") func anIndexlessDraftStillHasChips() throws { // The two-step-create shape, and the shape a drop-before-a-keystroke leaves. Reporting `nil` // would hide files the user can see in Finder. let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8)) let draft = try #require(CommentThread.loadDraft(inCard: fixture.url(card))) #expect(draft.body == "") #expect(draft.attachments == ["shot.png"]) #expect(!draft.isEmpty, "a draft with no text but a file in it is still a draft") } @Test("Emptiness is the Writer's own gate, asked forwards") func emptinessMirrorsTheDeleteRule() { #expect(CommentDraft(body: "", attachments: []).isEmpty) #expect(CommentDraft(body: " \n ", attachments: []).isEmpty) #expect(!CommentDraft(body: "", attachments: ["shot.png"]).isEmpty) #expect(!CommentDraft(body: "x", attachments: []).isEmpty) } @Test("A .draft held by a file is not a draft") func aSquattedDraftNameIsNotADraft() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.file("\(card)/comments/.draft", Data("not a folder".utf8)) #expect(CommentThread.loadDraft(inCard: fixture.url(card)) == nil) } } // MARK: - Importing @Suite("Comments ▸ authoring attachments") struct CommentAttachmentImportTests { private func source(_ fixture: WriterFixture, named name: String, _ text: String = "x") throws -> URL { try fixture.file("sources/\(name)", Data(text.utf8)) } @Test("A drop in the composer lands in the draft's attachments/") func importsIntoTheDraft() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText()) let file = try source(fixture, named: "shot.png") let landed = try BoardWriter.importCommentAttachments( [file], intoComment: CommentThread.draftFolder(inCard: fixture.url(card)) ) #expect(landed.map(\.fileName) == ["shot.png"]) #expect(fixture.exists("\(card)/comments/.draft/attachments/shot.png")) } @Test("A drop in an inline edit session lands in that comment's attachments/") func importsIntoAComment() 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()) let file = try source(fixture, named: "shot.png") _ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(path)) #expect(fixture.exists("\(path)/attachments/shot.png")) } @Test("A name already taken is renamed Finder-style, never overwritten") func collisionsRenameFinderStyle() throws { // The card level's rule, shared rather than restated: `importFiles` is one implementation, so // a file added to a comment behaves exactly like one added to a card. let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText()) try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("first".utf8)) let file = try source(fixture, named: "shot.png", "second") let landed = try BoardWriter.importCommentAttachments( [file], intoComment: CommentThread.draftFolder(inCard: fixture.url(card)) ) #expect(landed.map(\.fileName) == ["shot 2.png"]) #expect(try fixture.data("\(card)/comments/.draft/attachments/shot.png") == Data("first".utf8)) } @Test("Importing opens no index.md — a file says nothing about the text beside it") func importStampsNothing() 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()) let before = try fixture.indexText(path) let file = try source(fixture, named: "shot.png") _ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(path)) #expect(try fixture.indexText(path) == before, "the relocation rule: no stamp for a file move") } @Test("A folder is refused, as it is at card level") func foldersAreRefused() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText()) let folder = fixture.url("sources/afolder") try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let failure = writeFailure { _ = try BoardWriter.importCommentAttachments( [folder], intoComment: CommentThread.draftFolder(inCard: fixture.url(card)) ) } #expect(failure?.operation == .importAttachment(filename: "afolder")) } @Test("comments/.trash/ is not an authoring surface") func theTrashRefusesAnImport() throws { // A deleted comment is undo's backing store and "never a UI surface" (01-storage-format.md § // Enhanced schema), so there is no gesture that could aim at one — and the Writer says so // rather than trusting that. let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) let file = try source(fixture, named: "shot.png") let failure = writeFailure { _ = try BoardWriter.importCommentAttachments( [file], intoComment: CommentThread.trashedCommentFolder( ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card) ) ) } #expect(failure != nil) } @Test("A card folder is not a comment folder either") func theCardIsRefused() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) let file = try source(fixture, named: "shot.png") let failure = writeFailure { _ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(card)) } #expect(failure != nil) } } // MARK: - Removing @Suite("Comments ▸ removing an authoring chip") struct CommentAttachmentRemoveTests { @Test("Remove moves the file to the system Trash — never a hard delete") func removeTrashesTheFile() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText()) try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8)) let trashed = try BoardWriter.removeCommentAttachment( named: "shot.png", fromComment: CommentThread.draftFolder(inCard: fixture.url(card)) ) let landing = try #require(trashed) #expect(FileManager.default.fileExists(atPath: landing.path), "the file still exists, in the Trash") #expect(!fixture.exists("\(card)/comments/.draft/attachments/shot.png")) try? FileManager.default.removeItem(at: landing) } @Test("A name that is no longer there is not a failure") func aVanishedNameIsANoOp() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText()) #expect(try BoardWriter.removeCommentAttachment( named: "gone.png", fromComment: CommentThread.draftFolder(inCard: fixture.url(card)) ) == nil) } } // MARK: - The store bracket @MainActor @Suite("Comments ▸ authoring attachments at the store") struct CommentAttachmentStoreTests { @Test("The store's import and remove land through the ordinary bracket") func theStoreBracketsBoth() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText()) let file = try fixture.file("sources/shot.png", Data("png".utf8)) let store = try BoardStore(rootURL: fixture.root) let cardID = ItemID(rawValue: Ident.card1) store.importCommentAttachments([file], inCard: cardID, target: .draft) #expect(fixture.exists("\(card)/comments/.draft/attachments/shot.png")) store.removeCommentAttachment(named: "shot.png", inCard: cardID, target: .draft) #expect(!fixture.exists("\(card)/comments/.draft/attachments/shot.png")) } @Test("A card that is not on the board writes nothing") func aVanishedCardWritesNothing() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try makeCommentBoard(fixture) let file = try fixture.file("sources/shot.png", Data("png".utf8)) let store = try BoardStore(rootURL: fixture.root) store.importCommentAttachments([file], inCard: ItemID(rawValue: Ident.card4), target: .draft) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card4)")) } @Test("The draft read reaches the store the same way the thread read does") func theStoreReadsTheDraft() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) try fixture.item("\(card)/comments/.draft", commentText(body: "restored\n")) let store = try BoardStore(rootURL: fixture.root) #expect(store.commentDraft(inCard: ItemID(rawValue: Ident.card1))?.body == "restored\n") #expect(store.commentDraft(inCard: ItemID(rawValue: Ident.card4)) == nil, "a card that is not there") } }