import Foundation import Testing @testable import Kanban /// The Edit buffer's state machine (05-card-window.md ▸ Edit, ▸ Write rules): the three write gates, /// the ~700 ms debounce, and dirty-buffer-wins. /// /// The session is deliberately a buffer and a clock with a *closure* for its destination, which is /// what lets this suite be about the rules rather than about files: the fake below records every /// save it is asked for, so "writes nothing" is an assertion about a count rather than an inference /// from an `mtime`. The bytes those saves put on disk are `BodyWriteTests`'. // MARK: - The fake destination /// A stand-in for `BoardStore.writeCardBody`, recording what it was asked to write and answering /// with whatever outcome the test wants. @MainActor private final class SaveSpy { private(set) var written: [String] = [] var outcome: CardBodyWriteOutcome = .written var count: Int { written.count } var last: String? { written.last } func save(_ text: String) -> CardBodyWriteOutcome { written.append(text) return outcome } } @MainActor private func makeSession(_ spy: SaveSpy, body: String = "original\n") -> CardBodyEditSession { let session = CardBodyEditSession() // Fast enough that a test never waits on the real 700 ms, slow enough that a keystroke arriving // right after another can still cancel it — the `DragSession.holdTimeout` precedent. session.debounceInterval = .milliseconds(30) session.save = { [spy] text in spy.save(text) } session.adopt(diskBody: body) return session } /// Polls until `condition` holds or the deadline passes — the suite's shape for anything the /// debounce has to actually elapse for. @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 three gates @MainActor @Suite("Card body ▸ the write gates") struct CardBodyWriteGateTests { @Test("An untouched session writes nothing, ever") func anUntouchedSessionWritesNothing() async { let spy = SaveSpy() let session = makeSession(spy) // Opened, read, and left — the flush that leaving Edit performs, on a buffer nobody typed // into. "An untouched body is never rewritten" (05 ▸ Write rules). #expect(session.flush() == .unchanged) await waitUntil { spy.count > 0 } #expect(spy.count == 0) #expect(session.saveAttempts == 0) } @Test("A typed-then-reverted edit writes nothing, and leaves no timer standing") func aRevertedEditWritesNothing() async { let spy = SaveSpy() let session = makeSession(spy) session.edited("original\nand more") #expect(session.isDirty) // Undone before the debounce could fire. session.edited("original\n") #expect(!session.isDirty) // The pending save was cancelled by the revert rather than firing on a no-op — a write that // landed here would stamp `modified` and mint a commit for nothing. await waitUntil { spy.count > 0 } #expect(spy.count == 0) #expect(session.flush() == .unchanged) #expect(spy.count == 0) } @Test("The echo of the app's own save is not written back") func anEchoIsNotWrittenBack() async { let spy = SaveSpy() let session = makeSession(spy) session.edited("typed\n") #expect(session.flush() == .written) #expect(spy.written == ["typed\n"]) // The watcher's reload arrives carrying what we just wrote. The buffer is clean against it, // so nothing is written back — which is what stops a save ringing forever round the one-way // flow. session.adopt(diskBody: "typed\n") #expect(!session.isDirty) #expect(session.flush() == .unchanged) await waitUntil { spy.count > 1 } #expect(spy.count == 1) } @Test("An external edit under a clean buffer is not written back either") func aForeignEditUnderACleanBufferIsNotWrittenBack() async { let spy = SaveSpy() let session = makeSession(spy) // Somebody else rewrote the file while the window sat there reading it. "A clean buffer // follows disk" (05 ▸ Write rules) — and following disk is not a reason to write to it. session.adopt(diskBody: "theirs\n") #expect(session.text == "theirs\n") #expect(!session.isDirty) #expect(session.flush() == .unchanged) await waitUntil { spy.count > 0 } #expect(spy.count == 0) } } // MARK: - Dirty-buffer-wins @MainActor @Suite("Card body ▸ dirty-buffer-wins") struct CardBodyDirtyBufferTests { @Test("A snapshot never reloads a dirty buffer under the cursor") func aDirtyBufferKeepsItsText() { let spy = SaveSpy() let session = makeSession(spy) session.edited("mine, unsaved\n") // The board, Preview and every other window take the new snapshot; this buffer does not. session.adopt(diskBody: "theirs\n") #expect(session.text == "mine, unsaved\n") #expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it") #expect(session.isDirty) } @Test("The buffer's own save then lands over the foreign edit — last writer wins") func theFlushOverwritesTheForeignEdit() { let spy = SaveSpy() let session = makeSession(spy) session.edited("mine, unsaved\n") session.adopt(diskBody: "theirs\n") #expect(session.flush() == .written) #expect(spy.written == ["mine, unsaved\n"], "deliberate last-writer-wins (05 ▸ Write rules)") #expect(!session.isDirty) } @Test("A foreign edit that happens to match the buffer settles it clean, writing nothing") func aConvergentForeignEditNeedsNoWrite() async { let spy = SaveSpy() let session = makeSession(spy) session.edited("same text\n") // An agent wrote exactly what the user was typing. The file already says what they mean, so // re-writing it would only stamp `modified`. session.adopt(diskBody: "same text\n") #expect(!session.isDirty) await waitUntil { spy.count > 0 } #expect(spy.count == 0) } @Test("A failed save keeps the buffer dirty, and the text") func aFailureKeepsTheText() { let spy = SaveSpy() let session = makeSession(spy) spy.outcome = .failed(BoardWriteError( operation: .editBody(title: "Notes"), path: "/x/index.md", reason: .io(message: "the disk is full") )) session.edited("precious\n") let outcome = session.flush() guard case .failed = outcome else { Issue.record("expected the failure to be reported, got \(outcome)") return } // "Nothing is lost while the window stays open" (02 § Write-failure surfacing): the text is // still here, still dirty, and the next flush will try again. #expect(session.text == "precious\n") #expect(session.isDirty) } @Test("A suspended save — the read-only lock — also keeps the buffer, and does not throw a close") func aSuspendedSaveKeepsTheBuffer() throws { let spy = SaveSpy() let session = makeSession(spy) spy.outcome = .suspended(.unwritableLocation(.permissionDenied)) session.edited("held\n") #expect(session.flush() == .suspended(.unwritableLocation(.permissionDenied))) #expect(session.isDirty) // The close-time guard treats it as a non-failure: no write was attempted, the lock row has // been standing all along, and a modal offering Try Again could only fail again. try session.flushOrThrow() } @Test("A real failure is what the close-time modal is raised on") func aFailureThrowsForTheGuard() { let spy = SaveSpy() let session = makeSession(spy) spy.outcome = .failed(BoardWriteError(operation: .editBody(title: nil), path: "/x", reason: .io(message: "nope"))) session.edited("unsaved\n") #expect(throws: BoardWriteError.self) { try session.flushOrThrow() } } @Test("A session with nowhere to write keeps its text rather than reporting success") func aSessionWithNoDestinationHoldsOn() { let session = CardBodyEditSession() session.adopt(diskBody: "start\n") session.edited("typed\n") #expect(session.flush() == .vanished) #expect(session.isDirty) #expect(session.text == "typed\n") } } // MARK: - The debounce @MainActor @Suite("Card body ▸ the debounce") struct CardBodyDebounceTests { @Test("Typing saves once the keystrokes stop") func typingSavesAfterTheInterval() async { let spy = SaveSpy() let session = makeSession(spy) session.edited("t\n") #expect(spy.count == 0, "not on the keystroke itself") await waitUntil { spy.count == 1 } #expect(spy.written == ["t\n"]) #expect(!session.isDirty) } @Test("A burst of keystrokes is one save, of the last text") func aBurstCoalesces() async { let spy = SaveSpy() let session = makeSession(spy) for text in ["a", "ab", "abc", "abcd"] { session.edited(text) } await waitUntil { spy.count >= 1 } // Trailing debounce: each keystroke restarts the clock, so the run costs one write rather // than one per character. #expect(spy.written == ["abcd"]) } @Test("A flush does not wait for the debounce, and the debounce does not fire behind it") func aFlushPreemptsThePendingSave() async { let spy = SaveSpy() let session = makeSession(spy) session.edited("typed\n") #expect(session.flush() == .written) #expect(spy.count == 1, "the flush wrote immediately — no flush lag on a mode exit (05)") // And the timer it cancelled does not come back to write the same text a second time. await waitUntil { spy.count > 1 } #expect(spy.count == 1) } @Test("The production interval is ~700 ms") func theDefaultIntervalIsTheDesignsNumber() { // The only thing the seam must not do is quietly change the number the design settled // (05 ▸ Edit: "Saved on a ~700 ms debounce"). #expect(CardBodyEditSession().debounceInterval == .milliseconds(700)) } @Test("Ending the session flushes — the Edit→Preview flip is the effective Save") func endingTheSessionFlushes() { let spy = SaveSpy() let session = makeSession(spy) session.edited("typed on the way out\n") #expect(session.endEditSession() == .written) #expect(spy.written == ["typed on the way out\n"]) } } // MARK: - The mode flip @MainActor @Suite("Card body ▸ leaving Edit flushes") struct CardBodyModeFlushTests { @Test("Every way out of Edit flushes first") func leavingEditFlushes() { let presentation = CardBodyPresentation() var flushes = 0 presentation.flushEdits = { flushes += 1 } presentation.openIfNeeded(body: "") #expect(presentation.mode == .edit) // ⌘E / the menu item's toggle … presentation.toggleMode() #expect(presentation.mode == .preview) #expect(flushes == 1) // … Escape in the editor, which is the same flip through the same door … presentation.setMode(.edit) #expect(flushes == 1, "entering Edit flushes nothing — there is nothing to flush yet") presentation.setMode(.preview) #expect(flushes == 2) } @Test("Setting the mode it already has flushes nothing") func aRedundantSetIsNotAFlush() { let presentation = CardBodyPresentation() var flushes = 0 presentation.flushEdits = { flushes += 1 } presentation.setMode(.edit) presentation.setMode(.edit) presentation.setMode(.edit) #expect(flushes == 0, "a re-published focus value must not commit an untouched buffer") } }