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
477 lines
16 KiB
Swift
477 lines
16 KiB
Swift
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")
|
|
}
|
|
}
|