Files
lanework/KanbanTests/CommentAttachmentWriteTests.swift
rzen fe3ffac48e Comments, phase 2 — the pane, the composer, and the inline session
The card window recomposes into three componentized panes (body,
comments, attributes) with two mounts — beside or body-over-comments
at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is
one persisted app-wide bit, no content-derived auto-show; File ▸ Add
Comment flips it on and focuses the composer. The thread renders
author lines, edited markers, card-subset Markdown bodies, and
read-only Quick Look chips under a count header with the sort-
direction control. The composer edits comments/.draft/ on the slow
cadence (blur, close, quit, ~30s interval), Escape only moves focus,
⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms
debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start
bytes, close flushes. File drops within either authoring surface
carve out of the window-wide card default into that surface's
attachments/; paperclips cover the no-drag path. Close flush runs
inline flush, then draft save, then the comments/.trash purge;
open sweeps crash residue.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 20:19:52 -04:00

285 lines
12 KiB
Swift

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