The agent-written test inited BoardStore on a bare WriterFixture temp dir; the fail-fast loader refuses that, so the test failed on its own branch despite the reported green. Swapped to the file's own makeBoard() fixture. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
234 lines
10 KiB
Swift
234 lines
10 KiB
Swift
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() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
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")
|
|
}
|
|
}
|