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
This commit is contained in:
2026-07-30 20:19:52 -04:00
parent f68ac3668e
commit fe3ffac48e
27 changed files with 4496 additions and 24 deletions
+261
View File
@@ -0,0 +1,261 @@
import CoreGraphics
import Foundation
import Testing
@testable import Kanban
/// The comments pane's **pure** seams: where it mounts, which way the thread runs, what a comment's
/// author line says, and which folder a dropped file lands in (05-card-window.md Composition, The
/// comments column; Attachments' carve-out).
///
/// Every one of them is a rule that fails silently in a running window a 3:2 split that is 2:3
/// looks deliberate, a composer at the wrong end looks like a design choice, and a file that landed
/// in the card's folder instead of the draft's is not visible at all until someone opens Finder. So
/// they are values and functions, and this is their suite; the views that arrange them are the
/// manual-verification list's.
// MARK: - The mount
@Suite("Comments ▸ where the pane mounts")
struct CommentsMountTests {
@Test("The menu row's bit reads one way: checked is beside")
func theBitReadsOneWay() {
#expect(CommentsMount(besideBody: true) == .beside)
#expect(CommentsMount(besideBody: false) == .stacked)
}
@Test("Stacked splits three parts body to two parts comments")
func stackedSplitsThreeToTwo() {
let mount = CommentsMount.stacked
#expect(mount.bodyHeight(in: 500) == 300)
#expect(mount.commentsHeight(in: 500) == 200)
}
@Test("The two heights always add up to the window — no gap, no overlap")
func theHeightsSum() {
for height in [0.0, 1.0, 37.0, 500.0, 1013.5] as [CGFloat] {
let mount = CommentsMount.stacked
#expect(mount.bodyHeight(in: height) + mount.commentsHeight(in: height) == height)
}
}
@Test("Beside, the body owns the full height and the comments pane divides none of it")
func besideDividesNoHeight() {
#expect(CommentsMount.beside.bodyHeight(in: 500) == 500)
#expect(CommentsMount.beside.commentsHeight(in: 500) == 0)
}
@Test("A negative proposed height is clamped rather than laid out")
func negativeHeightsClamp() {
#expect(CommentsMount.stacked.bodyHeight(in: -10) == 0)
#expect(CommentsMount.beside.bodyHeight(in: -10) == 0)
}
@Test("Only the beside mount widens the window")
func onlyBesideWidens() {
// "The window's minimum width grows only while the column is shown side-by-side" (05
// Composition) which is the entire reason the stacked mount exists.
#expect(CommentsMount.beside.widensWindow)
#expect(!CommentsMount.stacked.widensWindow)
}
}
// MARK: - The window's minimum
@Suite("Comments ▸ the window minimum")
struct CommentsWindowMinimumTests {
@Test("The minimum grows by the comments floor only with the column beside the body")
func theMinimumGrowsOnlyBeside() {
let without = CardWindowMetrics.minimumSize(bodyPointSize: 13)
let with = CardWindowMetrics.minimumSize(bodyPointSize: 13, commentsColumn: true)
#expect(with.width == without.width + CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13))
#expect(with.height == without.height, "a stacked pane divides height rather than demanding more")
}
@Test("Every comments measurement scales with the body font")
func measurementsScale() {
// 10-accessibility.md Text: "metrics derive from font metrics, so layout survives the
// largest system text sizes". The pane's numbers are no exception to the window's rule.
#expect(
CardWindowMetrics.commentsColumnWidth(bodyPointSize: 18)
> CardWindowMetrics.commentsColumnWidth(bodyPointSize: 11)
)
#expect(
CardWindowMetrics.composerHeight(bodyPointSize: 18)
> CardWindowMetrics.composerHeight(bodyPointSize: 11)
)
#expect(
CardWindowMetrics.inlineEditorHeight(bodyPointSize: 13)
> CardWindowMetrics.composerHeight(bodyPointSize: 13),
"an inline editor opens over text that already exists"
)
}
}
// MARK: - The sort direction
@Suite("Comments ▸ the sort direction")
struct CommentSortDirectionTests {
private func comments(_ ids: [String]) -> [Kanban.Comment] {
ids.map { id in
Kanban.Comment(
id: ItemID(rawValue: id),
schema: .valid(1),
author: .missing,
created: .missing,
modified: .missing,
modifiedBy: .missing,
attachments: [],
document: FrontmatterDocument(body: "")
)
}
}
@Test("Ascending is the default, and the control's bit reads one way")
func ascendingIsTheDefault() {
#expect(CommentSortDirection(newestFirst: false) == .ascending)
#expect(CommentSortDirection(newestFirst: true) == .descending)
#expect(!CommentSortDirection.ascending.isNewestFirst)
}
@Test("Descending is the loader's order reversed, never a second sort")
func descendingReverses() {
let thread = comments([CommentIdent.one, CommentIdent.two, CommentIdent.three])
#expect(CommentSortDirection.ascending.apply(to: thread).map(\.id) == thread.map(\.id))
#expect(CommentSortDirection.descending.apply(to: thread).map(\.id) == thread.reversed().map(\.id))
}
@Test("An empty thread reverses to an empty thread")
func emptyReverses() {
#expect(CommentSortDirection.descending.apply(to: []).isEmpty)
}
@Test("The composer sits at the newest end — bottom ascending, top descending")
func theComposerSitsAtTheNewestEnd() {
#expect(!CommentSortDirection.ascending.placesComposerFirst)
#expect(CommentSortDirection.descending.placesComposerFirst)
}
@Test("The control names the order it would give, and names it once")
func theControlLabelIsOneString() {
#expect(CommentSortDirection.ascending.controlLabel == "Oldest First")
#expect(CommentSortDirection.descending.controlLabel == "Newest First")
}
}
// MARK: - The header
@Suite("Comments ▸ the header count")
struct CommentsHeaderTests {
@Test("An empty thread still counts — the pane is an invitation, not an absence")
func zeroIsACount() {
#expect(CommentsHeader.title(count: 0) == "Comments · 0")
#expect(CommentsHeader.title(count: 3) == "Comments · 3")
}
}
// MARK: - The author line
@Suite("Comments ▸ the author line")
struct CommentAuthorLineTests {
@Test("Name and timestamp join with the window's own separator")
func nameAndTimestamp() {
#expect(
CommentAuthorLine.text(author: "Ada Lovelace", timestamp: "1 Jan 2026", isEdited: false)
== "Ada Lovelace · 1 Jan 2026"
)
}
@Test("A missing author renders without a name — never a placeholder string")
func missingAuthorRendersNothingInItsPlace() {
// "Missing renders unattributed" (`Comment.author`), and unattributed is the absence of a
// name: a placeholder there would be the app inventing an identity for a file that carries
// none, which is the same reason there are no avatars.
#expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
}
@Test("An empty author string is absent too — the Writer never writes one")
func blankAuthorIsAbsent() {
#expect(CommentAuthorLine.text(author: "", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
#expect(CommentAuthorLine.text(author: " ", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
}
@Test("'edited' appends when modified differs from created")
func editedAppends() {
#expect(
CommentAuthorLine.text(author: "Ada", timestamp: "1 Jan 2026", isEdited: true)
== "Ada · 1 Jan 2026 · edited"
)
#expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: true) == "1 Jan 2026 · edited")
}
@Test("A comment with neither a name nor a date renders no line at all")
func nothingToSayIsNoLine() {
#expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: false) == nil)
// Not even for the edit marker: "· edited" hangs off something, and a row saying only
// "edited" would be a row about nothing.
#expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: true) == nil)
}
}
// MARK: - The drop carve-out
@Suite("Comments ▸ the drop carve-out")
struct CommentDropCarveOutTests {
@Test("Everywhere else in the window is the card's — the default stands")
func theWindowDefaultStands() {
#expect(CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover()) == .card)
}
@Test("Within the composer's bounds, files land in the draft")
func composerLandsInTheDraft() {
#expect(
CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(isOverComposer: true))
== .comment(.draft)
)
}
@Test("Within an inline edit session's bounds, files land in that comment")
func inlineEditLandsInItsComment() {
let id = ItemID(rawValue: CommentIdent.one)
#expect(
CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(inlineEdit: id))
== .comment(.comment(id))
)
}
@Test("The inline session outranks the composer where they would ever overlap")
func theInlineSessionWins() {
// Moot today an inline session opens over a comment row and the composer is a separate
// surface and fixed anyway, because the case where it stops being moot is a layout change
// and a layout change must not silently move a user's files into the wrong folder.
let id = ItemID(rawValue: CommentIdent.one)
let hover = CommentDropCarveOut.Hover(isOverComposer: true, inlineEdit: id)
#expect(CommentDropCarveOut.landing(for: hover) == .comment(.comment(id)))
}
}
// MARK: - Where a target lives
@Suite("Comments ▸ the target's folder")
struct CommentTargetTests {
@Test("The draft and a posted comment resolve to their own folders, and the thread's rule owns both")
func targetsResolve() {
let card = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
let id = ItemID(rawValue: CommentIdent.one)
#expect(CommentTarget.draft.folder(inCard: card) == CommentThread.draftFolder(inCard: card))
#expect(
CommentTarget.comment(id).folder(inCard: card)
== CommentThread.commentFolder(id, inCard: card)
)
}
}
+647
View File
@@ -0,0 +1,647 @@
import Foundation
import Testing
@testable import Kanban
/// The card window's comments **handle** the object every menu row, every pane view and the close
/// flush actually talk to (05-card-window.md The comments column).
///
/// What it owns is orderings and lifecycles, which is exactly the class of thing that looks right in
/// a running window and is wrong: the sweep-before-read at open, the saves-before-purge at close, a
/// session that must be dropped when its comment vanishes underneath it. So the seams are closures,
/// this suite fills them with a log, and the assertions are about *sequence*.
///
/// The bytes are `CommentWriteTests`'; the buffers are `CommentSessionTests`'; the pure layout is
/// `CardCommentsLayoutTests`'.
// MARK: - The log
/// Every seam a `CardComments` can reach, recorded in the order it was reached.
@MainActor
private final class CommentsSpy {
enum Event: Equatable {
case sweep
case readThread
case readDraft
case purge
case displace([String])
case delete(String)
case edit(String, String)
case saveDraft(String)
case post
case importFiles([String], CommentTarget)
case removeFile(String, CommentTarget)
}
private(set) var events: [Event] = []
var thread: CommentThread = .empty
var draft: CommentDraft?
var deleteLands = true
var editLands = true
var postedID: ItemID? = ItemID(rawValue: CommentIdent.two)
func install(on comments: CardComments) {
comments.readThread = { [self] in events.append(.readThread); return thread }
comments.readDraft = { [self] in events.append(.readDraft); return draft }
comments.sweepTrashResidue = { [self] in events.append(.sweep) }
comments.purgeTrash = { [self] in events.append(.purge) }
comments.displaceSquatters = { [self] squatters in
events.append(.displace(squatters.map(\.name)))
}
comments.deleteComment = { [self] id in
events.append(.delete(id.rawValue))
return deleteLands
}
comments.editComment = { [self] id, body in
events.append(.edit(id.rawValue, body))
return editLands
}
comments.importAttachments = { [self] urls, target in
events.append(.importFiles(urls.map(\.lastPathComponent), target))
}
comments.removeAttachment = { [self] name, target in
events.append(.removeFile(name, target))
}
comments.composer.save = { [self] text in
events.append(.saveDraft(text))
return .updated
}
comments.composer.post = { [self] in
events.append(.post)
return postedID
}
}
}
/// A comment value with only the fields this suite reads the loader builds the real ones
/// (`CommentThreadTests`), and a thread assembled here is only ever a stand-in for one it produced.
private func comment(_ id: String, body: String = "text\n", attachments: [String] = []) -> Kanban.Comment {
Kanban.Comment(
id: ItemID(rawValue: id),
schema: .valid(1),
author: .missing,
created: .missing,
modified: .missing,
modifiedBy: .missing,
attachments: attachments,
document: FrontmatterDocument(body: body)
)
}
private func thread(_ comments: [Kanban.Comment], defects: [IntegrityRules.Defect] = []) -> CommentThread {
CommentThread(comments: comments, strays: [], defects: defects, hasDraft: false)
}
@MainActor
private func makeComments(_ spy: CommentsSpy) -> CardComments {
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
spy.install(on: comments)
return comments
}
// MARK: - Opening
@MainActor
@Suite("Card comments ▸ opening")
struct CardCommentsOpenTests {
@Test("The residue sweep runs before the thread read")
func sweepPrecedesTheRead() {
// The sweep *removes* folders, so a read taken before it would describe a
// `comments/.trash/` that is about to stop existing (01-storage-format.md § Enhanced schema).
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.open()
#expect(spy.events.prefix(2) == [.sweep, .readThread])
}
@Test("Opening reads the draft too — restore-on-reopen is just the read")
func openRestoresTheDraft() {
let spy = CommentsSpy()
spy.draft = CommentDraft(body: "half a thought\n", attachments: ["shot.png"])
let comments = makeComments(spy)
comments.open()
#expect(comments.composer.text == "half a thought\n")
#expect(comments.composer.attachments == ["shot.png"])
#expect(!comments.composer.isDirty)
}
@Test("A pane with no store reads nothing rather than showing an empty thread it invented")
func noSeamsIsNoRead() {
let comments = CardComments()
comments.reload()
#expect(comments.thread == .empty)
}
}
// MARK: - Reloading
@MainActor
@Suite("Card comments ▸ the live reload")
struct CardCommentsReloadTests {
@Test("A reload replaces the thread")
func reloadReplacesTheThread() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one)])
comments.reload()
#expect(comments.thread.comments.map(\.id.rawValue) == [CommentIdent.one])
}
@Test("Claimed-name squatters the read found are routed to the displacement")
func squattersAreDisplaced() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([], defects: [.claimedNameSquatted(ClaimedNameSquatter(
name: ".draft",
found: .file,
expected: .directory,
location: .commentThread(cardPath: "lane/card")
))])
comments.reload()
#expect(spy.events.contains(.displace([".draft"])))
}
@Test("A clean read displaces nothing — no bracket for a thread with no work in it")
func aCleanReadDisplacesNothing() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one)])
comments.reload()
#expect(!spy.events.contains { if case .displace = $0 { true } else { false } })
}
@Test("An open session follows disk while clean, and keeps its keystrokes while dirty")
func theSessionObeysDirtyBufferWins() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "original\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
spy.thread = thread([comment(CommentIdent.one, body: "an agent rewrote it\n")])
comments.reload()
#expect(comments.editing?.text == "an agent rewrote it\n")
comments.editing?.edited("mine\n")
spy.thread = thread([comment(CommentIdent.one, body: "and again\n")])
comments.reload()
#expect(comments.editing?.text == "mine\n")
}
@Test("A session whose comment vanished is dropped, with no error UI of its own")
func aVanishedCommentDropsItsSession() {
// 05 Deletion & lifecycle's "nowhere left to write", applied one level down: the store
// answers `false`/`nil` for a target that is gone, and there is nothing here to tell the user
// that `performWrite` has not already said.
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one)])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(comments.editing != nil)
spy.thread = .empty
comments.reload()
#expect(comments.editing == nil)
}
}
// MARK: - The inline session
@MainActor
@Suite("Card comments ▸ the inline edit session")
struct CardCommentsEditTests {
private func opened(_ spy: CommentsSpy) -> CardComments {
let comments = makeComments(spy)
spy.thread = thread([
comment(CommentIdent.one, body: "first\n"),
comment(CommentIdent.two, body: "second\n")
])
comments.reload()
return comments
}
@Test("Edit opens a session over the comment's current body")
func editOpensASession() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(comments.editing?.commentID.rawValue == CommentIdent.one)
#expect(comments.editing?.text == "first\n")
#expect(comments.editing?.sessionStart == "first\n")
}
@Test("One session at a time — opening a second commits the first")
func oneSessionAtATime() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("first, revised\n")
comments.beginEdit(ItemID(rawValue: CommentIdent.two))
#expect(spy.events.contains(.edit(CommentIdent.one, "first, revised\n")),
"the user asked to edit another comment — not to throw this one away")
#expect(comments.editing?.commentID.rawValue == CommentIdent.two)
}
@Test("Re-editing the comment already open is a no-op, not a restarted session")
func reEditingIsANoOp() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("typed\n")
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(comments.editing?.text == "typed\n", "a restart would lose the start-of-session bytes")
#expect(comments.editing?.sessionStart == "first\n")
}
@Test("Save flushes and closes the session")
func saveCommits() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("revised\n")
comments.commitEdit()
#expect(spy.events.contains(.edit(CommentIdent.one, "revised\n")))
#expect(comments.editing == nil)
}
@Test("Cancel writes the session-start bytes back and closes the session")
func cancelReverts() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("half a rewrite\n")
comments.editing?.flush()
comments.cancelEdit()
#expect(spy.events.contains(.edit(CommentIdent.one, "first\n")))
#expect(comments.editing == nil)
}
@Test("Under the read-only lock no session opens at all")
func theLockRefusesASession() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.isEditable = false
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(comments.editing == nil)
}
@Test("Editing a comment the read never found opens nothing")
func editingAVanishedCommentOpensNothing() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.three))
#expect(comments.editing == nil)
}
}
// MARK: - Delete
@MainActor
@Suite("Card comments ▸ delete")
struct CardCommentsDeleteTests {
@Test("Delete is immediate — no confirm, and the store's own step")
func deleteIsImmediate() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one)])
comments.reload()
comments.delete(ItemID(rawValue: CommentIdent.one))
#expect(spy.events.contains(.delete(CommentIdent.one)))
}
@Test("Deleting the comment being edited commits the session first")
func deleteCommitsTheOpenSession() {
// The user's last keystrokes belong in the file that is about to move into
// `comments/.trash/`, so an undo brings back what they wrote.
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("last words\n")
comments.delete(ItemID(rawValue: CommentIdent.one))
let edit = spy.events.firstIndex(of: .edit(CommentIdent.one, "last words\n"))
let delete = spy.events.firstIndex(of: .delete(CommentIdent.one))
#expect(edit != nil)
#expect(delete != nil)
#expect((edit ?? 0) < (delete ?? 0))
#expect(comments.editing == nil)
}
@Test("Under the read-only lock nothing is deleted")
func theLockRefusesADelete() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.isEditable = false
comments.delete(ItemID(rawValue: CommentIdent.one))
#expect(!spy.events.contains(.delete(CommentIdent.one)))
}
}
// MARK: - Attachments on the authoring surfaces
@MainActor
@Suite("Card comments ▸ authoring attachments")
struct CardCommentsAttachmentTests {
@Test("A drop on the composer imports into the draft; one on an inline session, into its comment")
func importsAimAtTheirSurface() {
let spy = CommentsSpy()
let comments = makeComments(spy)
let file = URL(fileURLWithPath: "/tmp/shot.png")
let id = ItemID(rawValue: CommentIdent.one)
comments.importFiles([file], to: .draft)
#expect(spy.events.contains(.importFiles(["shot.png"], .draft)))
comments.importFiles([file], to: .comment(id))
#expect(spy.events.contains(.importFiles(["shot.png"], .comment(id))))
}
@Test("An authoring chip's Remove aims at its own surface")
func removesAimAtTheirSurface() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.removeFile(named: "shot.png", from: .draft)
#expect(spy.events.contains(.removeFile("shot.png", .draft)))
}
@Test("Under the read-only lock neither import nor remove happens")
func theLockRefusesBoth() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.isEditable = false
comments.importFiles([URL(fileURLWithPath: "/tmp/shot.png")], to: .draft)
comments.removeFile(named: "shot.png", from: .draft)
#expect(spy.events.isEmpty)
}
@Test("A chip resolves against its own surface's attachments folder")
func chipURLsResolve() throws {
let spy = CommentsSpy()
let comments = makeComments(spy)
let id = ItemID(rawValue: CommentIdent.one)
let draft = try #require(comments.attachmentURL("shot.png", in: .draft))
#expect(draft.path.hasSuffix("/comments/.draft/attachments/shot.png"))
let posted = try #require(comments.attachmentURL("shot.png", in: .comment(id)))
#expect(posted.path.hasSuffix("/comments/\(CommentIdent.one)/attachments/shot.png"))
}
}
// MARK: - The close flush
@MainActor
@Suite("Card comments ▸ the close flush")
struct CardCommentsCloseTests {
@Test("Saves first, purge last")
func savesPrecedeThePurge() {
// The purge removes the folders a delete moved aside; running it before a session's save
// could remove a folder that save was about to write into.
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("mid-sentence\n")
comments.composer.edited("a draft\n")
comments.endSession()
let edit = try? #require(spy.events.firstIndex(of: .edit(CommentIdent.one, "mid-sentence\n")))
let draft = try? #require(spy.events.firstIndex(of: .saveDraft("a draft\n")))
let purge = try? #require(spy.events.firstIndex(of: .purge))
#expect((edit ?? 0) < (purge ?? 0))
#expect((draft ?? 0) < (purge ?? 0))
}
@Test("The close flushes the session — it never reverts it")
func theCloseIsNotACancel() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("kept\n")
comments.endSession()
#expect(spy.events.contains(.edit(CommentIdent.one, "kept\n")))
#expect(!spy.events.contains(.edit(CommentIdent.one, "first\n")), "a close is not an abandon")
}
@Test("Ending twice does the work once")
func endingTwiceIsIdempotent() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.composer.edited("a draft\n")
comments.endSession()
comments.endSession()
#expect(spy.events.filter { $0 == .saveDraft("a draft\n") }.count == 1)
}
@Test("A clean pane still purges — the trash is the app's leftovers, not the user's work")
func aCleanPaneStillPurges() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.endSession()
#expect(spy.events == [.purge])
}
}
// MARK: - Unsaved content
@MainActor
@Suite("Card comments ▸ what counts as unsaved")
struct CardCommentsUnsavedTests {
@Test("An inline edit session's dirty buffer counts")
func theInlineSessionCounts() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(!comments.holdsUnsavedContent)
comments.editing?.edited("typed\n")
#expect(comments.holdsUnsavedContent)
}
@Test("The composer's draft deliberately does not")
func theDraftDoesNotCount() {
// "Close and quit just proceed no DirtyBufferGuard, nothing to lose" (05 The comments
// column): the draft is a durable file being edited in place, not unsaved work, and this
// property's one consumer is File Save as Template's carve-out.
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.composer.edited("half a thought\n")
#expect(comments.composer.isDirty)
#expect(!comments.holdsUnsavedContent)
}
}
// MARK: - Add Comment
@MainActor
@Suite("Card comments ▸ File ▸ Add Comment")
struct AddCommentCommandTests {
@Test("The row turns Show Comments on and focuses the composer, whichever state it was in")
func theRowDoesBoth() {
// "File Add Comment flips the bit on when it's off (the gesture *is* the user choosing to
// see comments same persistence) and focuses the composer in one gesture" and it focuses
// *either way*, which is the clause that regresses silently.
#expect(AddCommentCommand.effect(isShown: false) == (isShown: true, focusesComposer: true))
#expect(AddCommentCommand.effect(isShown: true) == (isShown: true, focusesComposer: true))
}
@Test("Each request is its own — the counter never coalesces two")
func requestsAreCounted() {
let comments = CardComments()
#expect(comments.focusComposerRequests == 0)
comments.focusComposer()
comments.focusComposer()
#expect(comments.focusComposerRequests == 2)
}
@Test("All three comment rows are card-window-scoped and nothing else")
func theRowsAreScopedToACardWindow() {
#expect(!AddCommentCommand.isEnabled(nil))
#expect(!ShowCommentsCommand.isEnabled(nil))
#expect(!CommentsBesideBodyCommand.isEnabled(nil))
let comments = CardComments()
#expect(AddCommentCommand.isEnabled(comments))
#expect(ShowCommentsCommand.isEnabled(comments))
#expect(CommentsBesideBodyCommand.isEnabled(comments))
}
@Test("The lock does not disable them — showing a pane and focusing a buffer are not mutations")
func theLockLeavesThemAlone() {
let comments = CardComments()
comments.isEditable = false
#expect(AddCommentCommand.isEnabled(comments))
#expect(ShowCommentsCommand.isEnabled(comments))
}
}
// MARK: - The real wiring
@MainActor
@Suite("Card comments ▸ the store wiring")
struct CardCommentsWiringTests {
/// The **real** seams, over a real store `CardWindowHost.configureComments` is `static` and
/// takes its collaborators precisely so this can drive the production wiring rather than a
/// re-typed copy of it (`configureRawSource`'s precedent).
@Test("A draft composed, posted and re-read round-trips through the wired seams")
func theWiringRoundTrips() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: cardID)
comments.open()
comments.composer.edited("A remark.\n")
#expect(comments.composer.flush() == .created)
#expect(fixture.exists("\(card)/comments/.draft"))
// Re-reading is restore-on-reopen, and it is only the read.
comments.reload()
#expect(comments.composer.text == "A remark.\n")
#expect(comments.composer.postNow() != nil)
comments.reload()
#expect(comments.thread.comments.count == 1)
#expect(comments.thread.comments[0].body == "A remark.\n")
#expect(comments.composer.text == "", "the draft is gone — it is a comment now")
#expect(!fixture.exists("\(card)/comments/.draft"))
}
@Test("Delete moves into comments/.trash/, and the close purge empties it")
func deleteThenPurge() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
let store = try BoardStore(rootURL: fixture.root)
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1))
comments.open()
#expect(comments.thread.comments.count == 1)
comments.delete(ItemID(rawValue: CommentIdent.one))
#expect(comments.thread.comments.isEmpty)
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
comments.endSession()
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
@Test("An inline session's Cancel puts the file back through the wired save")
func cancelWritesThroughTheStore() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(body: "original\n"))
let store = try BoardStore(rootURL: fixture.root)
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1))
comments.open()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("rewritten\n")
comments.editing?.flush()
#expect(try fixture.indexText(commentPath(CommentIdent.one, inCard: card)).hasSuffix("rewritten\n"))
comments.cancelEdit()
#expect(try fixture.indexText(commentPath(CommentIdent.one, inCard: card)).hasSuffix("original\n"))
}
}
@@ -0,0 +1,284 @@
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")
}
}
+476
View File
@@ -0,0 +1,476 @@
import Foundation
import Testing
@testable import Kanban
/// The comments pane's **two buffers** and the cadences that separate them (05-card-window.md The
/// comments column):
///
/// - the composer's draft, whose saves are slow "composer blur, window close, quit, and a lazy
/// interval (~30 s) **not** the body editor's 700 ms";
/// - an inline comment edit, which *is* the body editor's 700 ms, plus the one thing the body has
/// not got: a Cancel that reverts to session-start bytes.
///
/// `CardBodyEditSessionTests`' shape, and for its reason: both types are a buffer and a clock with a
/// closure for a destination, so this suite is about the rules rather than about files the fakes
/// below record what they were asked to write, and "writes nothing" is an assertion about a count.
/// The bytes those saves put on disk are `CommentWriteTests`'.
// MARK: - Fakes
/// A stand-in for `BoardStore.saveCommentDraft` / `postComment`.
@MainActor
private final class DraftSpy {
private(set) var written: [String] = []
private(set) var posts = 0
/// `nil` is a save that did not land a failure, the read-only lock, a vanished card.
var outcome: CommentDraftOutcome? = .updated
var postedID: ItemID? = ItemID(rawValue: CommentIdent.one)
var count: Int { written.count }
func save(_ text: String) -> CommentDraftOutcome? {
written.append(text)
return outcome
}
func post() -> ItemID? {
posts += 1
return postedID
}
}
/// A stand-in for `BoardStore.editComment`.
@MainActor
private final class EditSpy {
private(set) var written: [String] = []
var lands = true
var count: Int { written.count }
var last: String? { written.last }
func save(_ text: String) -> Bool {
written.append(text)
return lands
}
}
@MainActor
private func makeComposer(_ spy: DraftSpy, draft: CommentDraft? = nil) -> CommentDraftSession {
let session = CommentDraftSession()
// Fast enough that a test never waits on the real 30 s. `CardBodyEditSessionTests`' precedent
// a production default on the property, the suite dialling it down.
session.saveInterval = .milliseconds(40)
session.save = { [spy] text in spy.save(text) }
session.post = { [spy] in spy.post() }
session.adopt(draft: draft)
return session
}
@MainActor
private func makeEditor(_ spy: EditSpy, body: String = "original\n") -> CommentEditSession {
let session = CommentEditSession(commentID: ItemID(rawValue: CommentIdent.one), body: body)
session.debounceInterval = .milliseconds(30)
session.save = { [spy] text in spy.save(text) }
return session
}
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
let start = ContinuousClock.now
while !condition() {
guard ContinuousClock.now - start < deadline else { return }
try? await Task.sleep(for: .milliseconds(5))
}
}
// MARK: - The composer's cadence
@MainActor
@Suite("Comment composer ▸ the slow cadence")
struct CommentComposerCadenceTests {
@Test("The production interval is ~30 s, not the body's 700 ms")
func theDefaultIntervalIsTheDesignsNumber() {
// The one thing the seam must not do is quietly become the body's cadence "so a Pro user's
// typing never becomes a commit stream" (05 The comments column).
#expect(CommentDraftSession().saveInterval == .seconds(30))
#expect(CommentDraftSession().saveInterval != CardBodyEditSession().debounceInterval)
}
@Test("A keystroke does not save; the interval does")
func theIntervalSaves() async {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("half a thought")
#expect(spy.count == 0, "not on the keystroke itself")
await waitUntil { spy.count == 1 }
#expect(spy.written == ["half a thought"])
#expect(!session.isDirty)
}
@Test("It is an interval, not a debounce: steady typing still lands a save")
func typingDoesNotPostponeTheSave() async {
let spy = DraftSpy()
let session = makeComposer(spy)
// A debounce would be restarted by each of these and never fire while the user typed. An
// interval fires on its own schedule which is the whole difference, and the reason the
// design says "lazy interval" rather than "debounce".
let start = ContinuousClock.now
while spy.count == 0, ContinuousClock.now - start < .seconds(2) {
session.edited(session.text + "a")
try? await Task.sleep(for: .milliseconds(5))
}
#expect(spy.count == 1)
}
@Test("Blur saves — the first of the four cadence moments")
func blurSaves() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("typed")
#expect(session.blurred() == .updated)
#expect(spy.written == ["typed"])
#expect(!session.isDirty)
}
@Test("A flush pre-empts the armed interval, and the interval does not fire behind it")
func flushPreemptsTheInterval() async {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("typed")
_ = session.flush()
#expect(spy.count == 1)
await waitUntil { spy.count > 1 }
#expect(spy.count == 1)
}
@Test("An untouched composer writes nothing, ever")
func anUntouchedComposerWritesNothing() async {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "restored\n", attachments: []))
#expect(session.text == "restored\n", "restore-on-reopen is just the read")
#expect(session.flush() == nil)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("A typed-then-reverted draft writes nothing, and leaves no timer standing")
func aRevertedDraftWritesNothing() async {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "restored\n", attachments: []))
session.edited("restored\nand more")
session.edited("restored\n")
#expect(!session.isDirty)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("A save that did not land keeps the buffer dirty, and the text")
func aFailedSaveKeepsTheText() {
let spy = DraftSpy()
let session = makeComposer(spy)
spy.outcome = nil
session.edited("precious")
#expect(session.flush() == nil)
#expect(session.text == "precious")
#expect(session.isDirty, "the read-only lock, a failure and a vanished card all keep the text")
}
@Test("Emptying the composer saves empty text — the Writer owns the delete rule")
func emptyingSavesEmptyText() {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "was here\n", attachments: []))
spy.outcome = .deleted
session.edited("")
#expect(session.flush() == .deleted)
#expect(spy.written == [""], "no second definition of 'no text and no attachments'")
#expect(!session.isDirty)
}
}
// MARK: - Dirty-buffer-wins, in the composer
@MainActor
@Suite("Comment composer ▸ dirty-buffer-wins")
struct CommentComposerAdoptTests {
@Test("A clean composer follows the file — a draft synced in arrives by itself")
func aCleanComposerFollowsDisk() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.adopt(draft: CommentDraft(body: "from another machine\n", attachments: ["shot.png"]))
#expect(session.text == "from another machine\n")
#expect(session.attachments == ["shot.png"])
#expect(!session.isDirty)
}
@Test("A dirty composer keeps its keystrokes under a reload")
func aDirtyComposerKeepsItsText() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("mine, unsaved")
session.adopt(draft: CommentDraft(body: "theirs\n", attachments: []))
#expect(session.text == "mine, unsaved")
#expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it")
#expect(session.isDirty)
}
@Test("Chips follow the file even while the text is dirty — a drop is not a keystroke")
func chipsFollowDiskWhileDirty() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("mid-sentence")
// The user dropped a file into the composer: the import is a write of its own, and its
// reload must show the chip without disturbing the text above it.
session.adopt(draft: CommentDraft(body: "", attachments: ["shot.png"]))
#expect(session.text == "mid-sentence")
#expect(session.attachments == ["shot.png"])
}
@Test("No draft at all is an empty, clean composer")
func noDraftIsEmpty() {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "here\n", attachments: []))
session.adopt(draft: nil)
#expect(session.text == "")
#expect(!session.isDirty)
}
}
// MARK: - Posting
@MainActor
@Suite("Comment composer ▸ posting")
struct CommentComposerPostTests {
@Test("There is nothing to post exactly when there would be nothing to keep")
func canPostMirrorsTheEmptiedDraftRule() {
let spy = DraftSpy()
let session = makeComposer(spy)
#expect(!session.canPost)
session.edited(" \n ")
#expect(!session.canPost, "whitespace is empty, the Writer's own gate")
session.edited("something")
#expect(session.canPost)
session.edited("")
session.adopt(draft: CommentDraft(body: "", attachments: ["shot.png"]))
#expect(session.canPost, "a draft with no text but a file in it is still a draft")
}
@Test("⌘↩ flushes before it posts — the slow cadence is never visible as lost text")
func postFlushesFirst() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("about to post")
#expect(session.postNow() != nil)
#expect(spy.written == ["about to post"], "the post renames a folder; unwritten text is not in it")
#expect(spy.posts == 1)
}
@Test("After a post the composer is empty and clean — the draft is gone")
func postEmptiesTheComposer() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("posted")
session.adopt(draft: CommentDraft(body: "posted", attachments: ["shot.png"]))
_ = session.postNow()
#expect(session.text == "")
#expect(session.disk == "")
#expect(session.attachments.isEmpty)
#expect(!session.isDirty)
}
@Test("An empty composer posts nothing at all")
func anEmptyComposerPostsNothing() {
let spy = DraftSpy()
let session = makeComposer(spy)
#expect(session.postNow() == nil)
#expect(spy.posts == 0)
#expect(spy.count == 0)
}
@Test("A post that did not land leaves the composer holding its text")
func aRefusedPostKeepsTheDraft() {
let spy = DraftSpy()
let session = makeComposer(spy)
spy.postedID = nil
session.edited("still mine")
#expect(session.postNow() == nil)
#expect(session.text == "still mine")
}
}
// MARK: - The inline edit session
@MainActor
@Suite("Inline comment edit ▸ the body session in miniature")
struct CommentEditSessionTests {
@Test("The cadence is the body's ~700 ms, not the draft's")
func theCadenceIsTheBodys() {
let session = CommentEditSession(commentID: ItemID(rawValue: CommentIdent.one), body: "")
#expect(session.debounceInterval == CardBodyEditSession().debounceInterval)
}
@Test("Typing saves once the keystrokes stop — crash safety without a commit point")
func typingSaves() async {
let spy = EditSpy()
let session = makeEditor(spy)
session.edited("revised\n")
#expect(spy.count == 0)
await waitUntil { spy.count == 1 }
#expect(spy.written == ["revised\n"])
#expect(!session.isDirty)
}
@Test("A burst is one save, of the last text")
func aBurstCoalesces() async {
let spy = EditSpy()
let session = makeEditor(spy)
for text in ["a", "ab", "abc"] { session.edited(text) }
await waitUntil { spy.count >= 1 }
#expect(spy.written == ["abc"])
}
@Test("An untouched session writes nothing, and ends writing nothing")
func anUntouchedSessionWritesNothing() async {
let spy = EditSpy()
let session = makeEditor(spy)
#expect(!session.commit())
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("Save is the commit point: it flushes and ends")
func commitFlushesAndEnds() {
let spy = EditSpy()
let session = makeEditor(spy)
session.edited("revised\n")
#expect(session.commit())
#expect(spy.written == ["revised\n"])
#expect(session.hasEnded)
#expect(!session.commit(), "a session ends once")
}
@Test("Cancel writes the session-start bytes back")
func cancelRevertsToSessionStart() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("half a rewrite\n")
#expect(session.flush())
#expect(spy.written == ["half a rewrite\n"])
#expect(session.cancel())
#expect(spy.written == ["half a rewrite\n", "original\n"])
#expect(session.text == "original\n")
#expect(session.hasEnded)
}
@Test("A cancel with nothing landed writes nothing — the file stays byte-identical")
func cancelAfterNoSaveWritesNothing() {
let spy = EditSpy()
let session = makeEditor(spy)
// Typed, but the debounce never fired: nothing of this session's is on disk, so there is
// nothing to put back and no `modified` stamp to spend.
session.edited("never landed\n")
#expect(!session.cancel())
#expect(spy.count == 0)
}
@Test("Cancel after several ticks reverts to the session's start, not to the last tick")
func cancelUndoesTheWholeSession() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("one\n")
_ = session.flush()
session.edited("two\n")
_ = session.flush()
session.edited("three\n")
_ = session.flush()
#expect(session.cancel())
#expect(spy.last == "original\n")
}
@Test("A window close flushes the session — it never reverts it")
func closeFlushesRatherThanReverts() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("mid-sentence\n")
#expect(session.endOnClose())
#expect(spy.written == ["mid-sentence\n"], "dismissal never eats typed work where a save can land")
#expect(session.hasEnded)
}
@Test("A save that did not land keeps the buffer dirty, and the text")
func aFailedSaveKeepsTheText() {
let spy = EditSpy()
let session = makeEditor(spy)
spy.lands = false
session.edited("precious\n")
#expect(!session.flush())
#expect(session.text == "precious\n")
#expect(session.isDirty)
}
@Test("Dirty-buffer-wins: a foreign edit never lands under the cursor")
func dirtyBufferWins() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("mine\n")
session.adopt(diskBody: "theirs\n")
#expect(session.text == "mine\n")
#expect(session.disk == "theirs\n")
// And a clean buffer follows disk.
let clean = makeEditor(spy, body: "original\n")
clean.adopt(diskBody: "theirs\n")
#expect(clean.text == "theirs\n")
#expect(!clean.isDirty)
}
@Test("Cancel after a foreign edit still writes the session's start bytes — last writer wins")
func cancelOverAForeignEdit() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.adopt(diskBody: "an agent rewrote it\n")
#expect(session.cancel())
#expect(spy.last == "original\n", "the same no-merge-UI philosophy the body's dirty-buffer rule states")
}
}