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