import Foundation import Testing @testable import Kanban /// The title field's buffer state machine — `CardBodyEditSessionTests`' own shape, one field over /// (`CardTitleEditSession`'s doc comment: "a body-edit session in miniature"). /// /// The write gates, dirty-buffer-wins, the debounce and the mode-flip flush are the same claims /// `CardBodyEditSessionTests` makes about the body, restated here because the two types are siblings /// rather than one type reused — see `CardTitleEditSession`'s doc comment for why. What is new to this /// suite is normalization (trim, empty → `nil`) and the newline filter; those get their own section. // MARK: - The fake destination /// A stand-in for `BoardStore.commitCardTitle`, recording what it was asked to write and answering /// with whatever outcome the test wants. @MainActor private final class TitleSaveSpy { private(set) var written: [String?] = [] var outcome: CardBodyWriteOutcome = .written var count: Int { written.count } var last: String?? { written.last } func save(_ title: String?) -> CardBodyWriteOutcome { written.append(title) return outcome } } @MainActor private func makeSession(_ spy: TitleSaveSpy, title: String? = "Original") -> CardTitleEditSession { let session = CardTitleEditSession() session.debounceInterval = .milliseconds(30) session.save = { [spy] title in spy.save(title) } session.adopt(diskTitle: title) return session } /// Polls until `condition` holds or the deadline passes — `CardBodyEditSessionTests`' own helper. @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 title ▸ the write gates") struct CardTitleWriteGateTests { @Test("An untouched session writes nothing, ever") func anUntouchedSessionWritesNothing() async { let spy = TitleSaveSpy() let session = makeSession(spy) #expect(session.flush() == .unchanged) await waitUntil { spy.count > 0 } #expect(spy.count == 0) #expect(session.saveAttempts == 0) } @Test("An untitled card opened and left writes nothing") func anUntitledSessionWritesNothing() async { let spy = TitleSaveSpy() let session = makeSession(spy, title: nil) #expect(session.text == "") #expect(session.flush() == .unchanged) await waitUntil { spy.count > 0 } #expect(spy.count == 0) } @Test("A typed-then-reverted edit writes nothing, and leaves no timer standing") func aRevertedEditWritesNothing() async { let spy = TitleSaveSpy() let session = makeSession(spy) session.edited("Original and more") #expect(session.isDirty) session.edited("Original") #expect(!session.isDirty) 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 = TitleSaveSpy() let session = makeSession(spy) session.edited("Typed") #expect(session.flush() == .written) #expect(spy.written == ["Typed"]) session.adopt(diskTitle: "Typed") #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 = TitleSaveSpy() let session = makeSession(spy) session.adopt(diskTitle: "Theirs") #expect(session.text == "Theirs") #expect(!session.isDirty) #expect(session.flush() == .unchanged) await waitUntil { spy.count > 0 } #expect(spy.count == 0) } } // MARK: - Normalization @MainActor @Suite("Card title ▸ normalization") struct CardTitleNormalizationTests { @Test("Whitespace commits as empty — a title of three spaces is a slip, not a name") func whitespaceIsEmpty() { let spy = TitleSaveSpy() let session = makeSession(spy, title: nil) session.edited(" ") // Untouched, in the write-gate's own terms: the untitled card's normalized text is still // `nil`, so there is nothing to write — `commitRename`'s own "whitespace commits as empty". #expect(!session.isDirty) #expect(session.flush() == .unchanged) #expect(spy.count == 0) } @Test("Padded text writes trimmed, not with its padding") func paddedTextIsTrimmedAtCommit() { let spy = TitleSaveSpy() let session = makeSession(spy, title: nil) session.edited(" Fix login ") #expect(session.isDirty) #expect(session.flush() == .written) #expect(spy.written == ["Fix login"]) } @Test("Clearing an existing title to spaces removes it") func clearingToSpacesRemovesTheTitle() { let spy = TitleSaveSpy() let session = makeSession(spy, title: "First") session.edited(" ") #expect(session.isDirty) #expect(session.flush() == .written) #expect(spy.written == [String?.none]) } @Test("Embedded newlines are stripped as they are typed — the single-line rule") func embeddedNewlinesAreStripped() { let spy = TitleSaveSpy() let session = makeSession(spy, title: nil) // A paste bringing in a hard return — never something a plain `TextField`'s Return key can // produce on its own (`CardWindowView.titleRow`'s `.onSubmit`), but a paste is not the same // door. session.edited("Foo\nBar\r\nBaz\r") #expect(session.text == "FooBarBaz", "no line break survives into the shown buffer") #expect(session.flush() == .written) #expect(spy.written == ["FooBarBaz"]) } @Test("A no-op edit — typing then retyping the same normalized text — writes nothing") func aNoOpNormalizedEditWritesNothing() async { let spy = TitleSaveSpy() let session = makeSession(spy, title: "Fix login") // Padding around the exact title already on disk normalizes to the same value. session.edited(" Fix login ") #expect(!session.isDirty, "byte-identity discipline: padding around an unchanged title is not a change") #expect(session.flush() == .unchanged) await waitUntil { spy.count > 0 } #expect(spy.count == 0) } } // MARK: - Dirty-buffer-wins @MainActor @Suite("Card title ▸ dirty-buffer-wins") struct CardTitleDirtyBufferTests { @Test("A snapshot never reloads a dirty buffer under the cursor") func aDirtyBufferKeepsItsText() { let spy = TitleSaveSpy() let session = makeSession(spy) session.edited("Mine, unsaved") session.adopt(diskTitle: "Theirs") #expect(session.text == "Mine, unsaved") #expect(session.disk == "Theirs") #expect(session.isDirty) } @Test("The buffer's own save then lands over the foreign edit — last writer wins") func theFlushOverwritesTheForeignEdit() { let spy = TitleSaveSpy() let session = makeSession(spy) session.edited("Mine, unsaved") session.adopt(diskTitle: "Theirs") #expect(session.flush() == .written) #expect(spy.written == ["Mine, unsaved"]) #expect(!session.isDirty) } @Test("A failed save keeps the buffer dirty, and the text") func aFailureKeepsTheText() { let spy = TitleSaveSpy() let session = makeSession(spy) spy.outcome = .failed(BoardWriteError( operation: .rename(title: "Original"), path: "/x/index.md", reason: .io(message: "the disk is full") )) session.edited("Precious") let outcome = session.flush() guard case .failed = outcome else { Issue.record("expected the failure to be reported, got \(outcome)") return } #expect(session.text == "Precious") #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 = TitleSaveSpy() let session = makeSession(spy) spy.outcome = .suspended(.unwritableLocation(.permissionDenied)) session.edited("Held") #expect(session.flush() == .suspended(.unwritableLocation(.permissionDenied))) #expect(session.isDirty) try session.flushOrThrow() } @Test("A real failure is what the close-time modal is raised on") func aFailureThrowsForTheGuard() { let spy = TitleSaveSpy() let session = makeSession(spy) spy.outcome = .failed(BoardWriteError(operation: .rename(title: nil), path: "/x", reason: .io(message: "nope"))) session.edited("Unsaved") #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 = CardTitleEditSession() session.adopt(diskTitle: "Start") session.edited("Typed") #expect(session.flush() == .vanished) #expect(session.isDirty) #expect(session.text == "Typed") } } // MARK: - The debounce @MainActor @Suite("Card title ▸ the debounce") struct CardTitleDebounceTests { @Test("Typing saves once the keystrokes stop") func typingSavesAfterTheInterval() async { let spy = TitleSaveSpy() let session = makeSession(spy, title: nil) session.edited("T") #expect(spy.count == 0, "not on the keystroke itself") await waitUntil { spy.count == 1 } #expect(spy.written == ["T"]) #expect(!session.isDirty) } @Test("A burst of keystrokes is one save, of the last text") func aBurstCoalesces() async { let spy = TitleSaveSpy() let session = makeSession(spy, title: nil) for text in ["a", "ab", "abc", "abcd"] { session.edited(text) } await waitUntil { spy.count >= 1 } #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 = TitleSaveSpy() let session = makeSession(spy, title: nil) session.edited("Typed") #expect(session.flush() == .written) #expect(spy.count == 1, "the flush wrote immediately — no flush lag on a mode exit (05)") await waitUntil { spy.count > 1 } #expect(spy.count == 1) } @Test("The production interval is ~700 ms — the body's own default") func theDefaultIntervalIsTheDesignsNumber() { #expect(CardTitleEditSession().debounceInterval == .milliseconds(700)) } @Test("Ending the session flushes — the Edit→Preview flip is the effective Save") func endingTheSessionFlushes() { let spy = TitleSaveSpy() let session = makeSession(spy, title: nil) session.edited("Typed on the way out") #expect(session.endEditSession() == .written) #expect(spy.written == ["Typed on the way out"]) } } // MARK: - The undo step @MainActor @Suite("Card title ▸ the undo step") struct CardTitleUndoTests { @Test("A session's net effect registers exactly once, at endEditSession") func oneStepPerSession() { let spy = TitleSaveSpy() let session = makeSession(spy, title: "First") var registered: [(String?, String?)] = [] session.registerUndo = { prior, new in registered.append((prior, new)) } // Two debounced ticks inside one session — still one step. session.edited("Second") _ = session.flush() session.edited("Third") _ = session.flush() _ = session.endEditSession() #expect(registered.count == 1) #expect(registered.first?.0 == "First", "the bytes before the session's first landed save") #expect(registered.first?.1 == "Third", "the bytes the session left") } @Test("A session that types its way back to where it started registers nothing") func aRoundTripRegistersNothing() { let spy = TitleSaveSpy() let session = makeSession(spy, title: "First") var registered = 0 session.registerUndo = { _, _ in registered += 1 } session.edited("Detour") _ = session.flush() session.edited("First") _ = session.endEditSession() #expect(registered == 0) } @Test("A session that only read registers nothing") func aReadOnlySessionRegistersNothing() { let spy = TitleSaveSpy() let session = makeSession(spy, title: "First") var registered = 0 session.registerUndo = { _, _ in registered += 1 } _ = session.endEditSession() #expect(registered == 0) #expect(spy.count == 0) } }