The card window's title learns Edit mode — a sibling session rides the body's own doors
CardWindowView's header is now the title, live: a static Text in Preview, an editable single-line TextField in Edit, both reading a new CardTitleEditSession's buffer rather than the card's own snapshot value — the same "buffer outranks the snapshot" reason the body surface already reads bodySession.text instead of card.body. CardTitleEditSession is CardBodyEditSession's shape one field over: the one-isDirty write gate, dirty-buffer-wins on adopt(diskTitle:), the ~700ms injectable debounce, flush() / flushOrThrow() for DirtyBufferGuard, and beginEditSession()/endEditSession() with a session-coalesced undo step (one per session, never per debounced tick). It rides the body's own begin/end/flush doors rather than opening a second session boundary — title is only ever editable while the body column is in Edit mode — because the two write through different WriteOperations with different validation and merging them would conflate two unrelated frontmatter keys behind one buffer. BoardStore.commitCardTitle(inCard:title:) reuses the same private setTitle helper and the same .rename WriteOperation the board's own inline rename commits through, so trimming, empty-removes-the-key, unchanged-writes-nothing and banner enrichment are one code path, not a re-implementation. It resolves through cardBodyTarget (spans lanes and the trash), not boardItem (board only), because a card window's title field stays live through the same dismissal-into-trash flush the body already gets — the one deliberate divergence from the board's own rename, which treats a trashed target as vanished. registerTitleEdit(inCard:priorTitle:newTitle🔛) mirrors registerBodyEdit, anchored by card identity and the already-reserved ExpectedField.title, folding into the same one-coarse-step-per-window-close undo model. Every place the body's Edit buffer flushes, the title's now does too: mode exit (bodyPresentation.flushEdits/beginEdits), window close and the dismissal path (CardWindowSession.endSession()), raw-source entry (configureRawSource), the close-time DirtyBufferGuard modal (attemptSave tries body then title), and the fast-path close gate (closeAfterFlushing() now checks title.isDirty alongside body.isDirty). holdsUnsavedContent and settlement carry the title too. Tests: CardTitleEditSessionTests.swift mirrors CardBodyEditSessionTests.swift (write gates, normalization and newline-stripping, dirty-buffer-wins, debounce, undo-step coalescing). CardTitleWriteTests.swift covers commitCardTitle/registerTitleEdit: byte-identity no-op, empty-removes-key, vanished, the trashed-card-is-still-writable divergence, a readable-but-uneditable target refusing and bannering, and a read-only board suspending quietly. CardSessionUndoTests.swift gains coverage that a title edit folds into the coarse close step alongside a body edit and registers on the window's own stack, never the board's. RawSourceTests.swift's hand-wired rig picks up the title session configureRawSource now also flushes. 3148 KanbanTests pass, 0 failures. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user