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:
2026-08-09 10:37:55 -04:00
parent aa8c54fc7a
commit c27cc93ec1
8 changed files with 1164 additions and 24 deletions
+77
View File
@@ -87,8 +87,15 @@ private func openWindow(_ fixture: WriterFixture, store: BoardStore, board: Nati
session.body.save = { [weak store] text in
store?.writeCardBody(inCard: cardID, body: text) ?? .vanished
}
// The title field's own write target `configureSession`'s seam, spelled the same way as the
// body's just above it.
session.title.save = { [weak store] title in
store?.commitCardTitle(inCard: cardID, title: title) ?? .vanished
}
session.comments.open()
session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
// `makeCommentBoard`'s fixture card is always titled "Fix login" `coarseStep`'s own constant.
session.title.adopt(diskTitle: "Fix login")
return Window(store: store, board: board, session: session)
}
@@ -98,6 +105,12 @@ private func editBody(_ window: Window, to text: String) {
window.body.endEditSession()
}
@MainActor
private func editTitle(_ window: Window, to text: String) {
window.session.title.edited(text)
window.session.title.endEditSession()
}
@MainActor
private func postComment(_ window: Window, body: String) -> ItemID? {
window.comments.composer.edited(body)
@@ -1012,3 +1025,67 @@ struct CardSessionFoldTests {
#expect(restored.expectations == [.present(live, .body("original\n"))])
}
}
// MARK: - The title field
/// `registerTitleEdit`'s own routing and fold, over the production wiring `CardWindowHost.configureUndo`
/// installs `CardSessionRoutingTests`' and `CardSessionCloseTests`' own claims, restated for the
/// header's title field rather than the body (`CardTitleWriteTests.swift` pins the write itself).
@MainActor
@Suite("Card session undo ▸ the title field")
struct CardSessionTitleTests {
@Test("A title edit registers on the window's own stack, never on the board's")
func windowGesturesStayOffTheBoardStack() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
editTitle(window, to: "Fix login, take two")
#expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps")
#expect(window.window.stack.canUndo)
#expect(window.window.stack.undoActionName == "Rename Card")
}
@Test("A title edit and a body edit in one session fold into one coarse step")
func foldsIntoTheCoarseStepAlongsideTheBody() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
let originalBody = try body(fixture, cardPath)
editBody(window, to: "Edited in the window.\n")
editTitle(window, to: "Fix login, take two")
await window.session.endSession()
#expect(window.board.undoActionName == coarseStep, "named for the card, not for either fine gesture")
window.board.undo()
let model = try BoardLoader.load(boardRoot: fixture.root).model
let restored = model.lanes.flatMap(\.cards).first { $0.id == cardID }
#expect(restored?.title == .valid("Fix login"), "the title is back too")
#expect(try body(fixture, cardPath) == originalBody)
window.board.redo()
let after = try BoardLoader.load(boardRoot: fixture.root).model
#expect(after.lanes.flatMap(\.cards).first { $0.id == cardID }?.title == .valid("Fix login, take two"))
#expect(try body(fixture, cardPath) == "Edited in the window.\n")
}
@Test("A title typed back to its starting value registers nothing")
func aRoundTripRegistersNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
window.session.title.edited("Detour")
_ = window.session.title.flush()
editTitle(window, to: "Fix login")
#expect(!window.window.stack.canUndo, "the session's net effect on the title is nothing")
}
}
+394
View File
@@ -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)
}
}
+237
View File
@@ -0,0 +1,237 @@
import Foundation
import Testing
@testable import Kanban
/// The card window title field's **write** path `BoardStore.commitCardTitle` and
/// `BoardStore.registerTitleEdit` `InlineEditWriteTests.swift`'s own shape, aimed at the seam the
/// card window's header uses instead of the board's inline rename editor.
///
/// Validation is deliberately the same as `InlineRenameWriteTests` pins for `commitRename`: trim,
/// empty removes the key, an unchanged title writes nothing, a readable-but-uneditable target refuses
/// and banners, a locked board suspends quietly. The one deliberate difference is resolution a card
/// window's title field stays live through the dismissal-into-trash flush the body already gets
/// (05-card-window.md Deletion & lifecycle), so it resolves through `cardBodyTarget` (both
/// containers) rather than `boardItem` (board only), and a trashed card is writable here where the
/// board's own inline rename would treat it as vanished.
// MARK: - Fixtures
/// One lane with two cards, plus a readable-but-uneditable lane `InlineEditWriteTests.swift`'s own
/// `makeBoard()`, trimmed to what this file's suites need.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(Ident.lane3, Item.uneditable)
return fixture
}
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
/// The file's lines minus the ones an app-mediated write is *supposed* to change
/// `InlineEditWriteTests.swift`'s own `untouchedLines`.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified") && !$0.hasPrefix("title:") && !$0.hasPrefix("kind:")
}
}
private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
private func card(_ id: ItemID, in model: BoardModel) -> Card? {
model.lanes.flatMap(\.cards).first { $0.id == id }
}
// MARK: - commitCardTitle
@MainActor
@Suite("Card title ▸ commitCardTitle")
struct CardTitleCommitWriteTests {
@Test("A non-empty commit writes the title, stamps modified, and touches nothing else")
func writesTheTitleAndStamps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
#expect(store.commitCardTitle(inCard: card1, title: "Fix login") == .written)
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
#expect(after.contains("title: Fix login"))
#expect(!after.contains("modified-by"))
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"))
#expect(untouchedLines(after) == untouchedLines(before))
let renamed = try #require(card(card1, in: load(fixture)))
#expect(renamed.title == .valid("Fix login"))
#expect(store.banners.oneShots.isEmpty)
}
@Test("An empty commit removes the title key, byte-faithfully")
func emptyCommitRemovesTheKey() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
#expect(store.commitCardTitle(inCard: card1, title: nil) == .written)
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
#expect(!after.contains("title:"))
#expect(!after.contains("title: \"\""))
#expect(untouchedLines(after) == untouchedLines(before))
let stripped = try #require(card(card1, in: load(fixture)))
#expect(stripped.title.isMissing)
#expect(stripped.order == 1024, "the card keeps its place")
}
@Test("An unchanged title writes nothing at all — byte-identity discipline")
func unchangedTitleIsANoOp() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
// The card window's session hands over an already-normalized title, so an end-editing commit
// of exactly what disk says must not stamp `modified` the field's own no-op end-editing rule.
#expect(store.commitCardTitle(inCard: card1, title: "First") == .unchanged)
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)") == ["index.md"], "no temp-file residue")
}
@Test("A commit at a card the snapshot does not have writes nothing, silently")
func vanishedTargetWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.commitCardTitle(inCard: ItemID(rawValue: Ident.indexless), title: "Never lands") == .vanished)
#expect(!fixture.exists(Ident.indexless))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A card in the trash is still writable here — the dismissal-into-trash flush's own target")
func aTrashedCardIsStillWritable() throws {
// "A dirty Edit buffer flushes into the card's folder at its new `.trash/` location before
// the window dismisses" (05-card-window.md Deletion & lifecycle) the title field rides the
// same rule, unlike the board's own inline rename (`InlineRenameWriteTests
// .targetInTheTrashWritesNothing`), which treats a trashed target as vanished.
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
let store = try BoardStore(rootURL: fixture.root)
#expect(store.commitCardTitle(inCard: card1, title: "Renamed while dismissing") == .written)
#expect(try fixture.indexText(".trash/\(Ident.card1)").contains("title: Renamed while dismissing"))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A readable-but-uneditable target refuses the write, banners it, and keeps its bytes")
func uneditableTargetBanners() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
let store = try BoardStore(rootURL: fixture.root)
let target = ItemID(rawValue: Ident.card3)
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card3)")
#expect(store.commitCardTitle(inCard: target, title: "Renamed") != .written)
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card3)") == before)
#expect(store.banners.oneShots.count == 1)
let posted = try #require(store.banners.oneShots.first)
// Enriched off the document the write refused, `commitRename`'s own rule the banner names
// the card by the title it still has rather than the one that failed to land.
#expect(posted.error.operation == .rename(title: "Odd"))
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — "))
}
@Test("A read-only board suspends the write without a second banner — the lock row already stands")
func readOnlyBoardSuspendsQuietly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
let outcome = store.commitCardTitle(inCard: card1, title: "Fix login")
guard case .suspended = outcome else {
Issue.record("expected .suspended, got \(outcome)")
return
}
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
}
}
// MARK: - registerTitleEdit
@MainActor
@Suite("Card title ▸ registerTitleEdit")
struct CardTitleRegisterEditTests {
@Test("A landed edit registers one step on the given stack, and undo restores the prior title")
func registersAndUndoes() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let board = NativeHistoryProvider()
store.history = board
#expect(store.commitCardTitle(inCard: card1, title: "Fix login") == .written)
store.registerTitleEdit(inCard: card1, priorTitle: "First", newTitle: "Fix login")
#expect(board.canUndo)
#expect(board.undoActionName == "Rename Card")
board.undo()
#expect(try #require(card(card1, in: load(fixture))).title == .valid("First"))
board.redo()
#expect(try #require(card(card1, in: load(fixture))).title == .valid("Fix login"))
}
@Test("No net change registers nothing")
func noChangeRegistersNothing() {
let fixture = try? WriterFixture()
guard let fixture else { Issue.record("fixture"); return }
defer { fixture.tearDown() }
guard let store = try? BoardStore(rootURL: fixture.root) else {
Issue.record("store")
return
}
let board = NativeHistoryProvider()
store.history = board
store.registerTitleEdit(inCard: card1, priorTitle: "Same", newTitle: "Same")
#expect(!board.canUndo)
}
@Test("A card window session step lands on the window's own stack, not the board's")
func landsOnTheWindowStack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let board = NativeHistoryProvider()
store.history = board
let window = CardWindowUndo()
#expect(store.commitCardTitle(inCard: card1, title: "Fix login") == .written)
store.registerTitleEdit(inCard: card1, priorTitle: "First", newTitle: "Fix login", on: window)
#expect(window.stack.canUndo)
#expect(!board.canUndo, "a window gesture never lands on the board stack while the window is open")
}
}
+12 -2
View File
@@ -526,10 +526,20 @@ struct RawSourceSessionTests {
return store.writeCardBody(inCard: cardID, body: text)
}
body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
presentation.flushEdits = { body.endEditSession() }
// The title field's own session not this suite's subject, but `configureRawSource` now
// flushes it alongside the body, so the wiring under test needs one to flush.
let title = CardTitleEditSession()
title.save = { [weak store] title in
guard let store else { return .vanished }
return store.commitCardTitle(inCard: cardID, title: title)
}
presentation.flushEdits = {
body.endEditSession()
title.endEditSession()
}
let raw = CardRawSourceSession()
CardWindowHost.configureRawSource(raw, body: body, presentation: presentation, store: store, cardID: cardID)
CardWindowHost.configureRawSource(raw, body: body, title: title, presentation: presentation, store: store, cardID: cardID)
return Rig(fixture: fixture, store: store, presentation: presentation, body: body, raw: raw)
}