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
285 lines
12 KiB
Swift
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")
|
|
}
|
|
}
|