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") } }