import Foundation import Testing @testable import Kanban /// The card window's Edit buffer meeting disk (05-card-window.md ▸ Edit, ▸ Write rules) — the second /// and larger of the app's two body writes, and the one whose guarantees are *negative*: the file /// this suite cares most about is the one that was never written. /// /// Three of 05's rules are only observable in bytes, so this suite reads bytes: an untouched session /// leaves the file byte-identical **and its `mtime` untouched** (a stamped no-op would satisfy the /// first and violate the promise), a real edit replaces the body span and nothing above it, and a /// reverted or echoed edit is not written at all. Like the rest of the write suites this drives real /// files in a temp board and never reads through the app's own snapshot. `WriterFixture`, `Ident` and /// `Item` come from `WriterTestSupport.swift`. // MARK: - Fixture private let originalBody = """ # Notes Some *prose* with a [link](https://example.com). - [ ] a task """ /// The card, with everything a body write must leave alone above the closing delimiter: an unknown /// key carrying an inline comment, a second unknown key in a shape the app never writes, a `created` /// from before today, and a foreign `modified-by`. private let editableCard = """ --- schema: 1 title: Notes order: 1024 project: lanework # agent overlay labels: [a, b, c] created: 2026-01-01T09:00:00Z modified: 2026-02-02T09:00:00Z modified-by: claude --- \(originalBody) """ private let cardPath = "\(Ident.lane1)/\(Ident.card1)" private let siblingPath = "\(Ident.lane1)/\(Ident.card2)" @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(cardPath, editableCard) try fixture.item(siblingPath, Item.rich(order: "2048", title: "Untouched")) return fixture } /// The card's body as it is on disk right now — split off at the closing delimiter by the same /// parser the writer used, so "the body" means the same thing in the test as in the app. private func body(of fixture: WriterFixture, _ relativePath: String) throws -> String { try FrontmatterDocument.parse(fixture.indexText(relativePath)).body } /// The file's frontmatter lines, minus the two the stamp owns — what has to be identical, comment /// and key order included. private func frontmatterLines(_ text: String) -> [String] { let lines = text.components(separatedBy: "\n") guard let closing = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "---" }) else { return lines } return lines[0 ..< closing].filter { !$0.hasPrefix("modified:") && !$0.hasPrefix("modified-by:") } } private func modificationDate(_ fixture: WriterFixture, _ relativePath: String) throws -> Date? { let url = fixture.url(relativePath).appendingPathComponent("index.md") return try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date } // MARK: - The write @MainActor @Suite("BoardWriter ▸ writeBody") struct WriteBodyTests { @Test("A real edit replaces the body span and leaves every frontmatter byte alone") func anEditReplacesOnlyTheBody() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.indexText(cardPath) let wrote = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Replaced.\n") #expect(wrote) #expect(try body(of: fixture, cardPath) == "Replaced.\n") // Key order, the unknown keys, the inline comment and `created` all survive — the round-trip // guarantee, which a body write inherits by editing the document rather than rebuilding it. let after = try fixture.indexText(cardPath) #expect(frontmatterLines(after) == frontmatterLines(before)) #expect(after.contains("project: lanework # agent overlay")) #expect(after.contains("labels: [a, b, c]")) #expect(after.contains("created: 2026-01-01T09:00:00Z")) } @Test("A body rewrite is an index.md rewrite, so it stamps modified and clears modified-by") func theWriteStamps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "New text.\n") let stamped = try #require(try FrontmatterDocument.parse(fixture.indexText(cardPath)).modified.value) #expect(stamped.timeIntervalSinceNow > -30) #expect(!(try fixture.indexText(cardPath).contains("modified-by"))) } @Test("Writing the body the file already has writes nothing at all — bytes and mtime") func anIdenticalBodyIsNeverReSerialized() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let before = try fixture.indexData(cardPath) let mtime = try modificationDate(fixture, cardPath) // Filesystem timestamps have coarse resolution; a write inside the same tick would be // invisible to the `mtime` half of the assertion, so give it a moment to be able to differ. Thread.sleep(forTimeInterval: 0.05) let wrote = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: originalBody) #expect(!wrote, "an untouched body is never re-serialized (05 ▸ Write rules)") #expect(try fixture.indexData(cardPath) == before) // The `mtime` is the point: a no-op that still stamped `modified` would keep the *body* // byte-identical while rewriting the file — which is the thing the rule forbids. #expect(try modificationDate(fixture, cardPath) == mtime) } @Test("A body that changed under the buffer is overwritten — this is not a staleness check") func aForeignEditIsOverwritten() throws { let fixture = try makeBoard() defer { fixture.tearDown() } // Somebody else rewrote the card while the buffer held unsaved keystrokes. try fixture.item(cardPath, editableCard.replacingOccurrences(of: "# Notes", with: "# Theirs")) try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Mine.\n") // "Deliberate last-writer-wins, the same no-merge-UI philosophy as sync" (05 ▸ Write rules). #expect(try body(of: fixture, cardPath) == "Mine.\n") } @Test("An empty body is a legal body, and CRLF frontmatter stays CRLF") func anEmptyBodyAndOddLineEndings() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let path = "\(Ident.lane1)/\(Ident.card3)" try fixture.item(path, "---\r\nschema: 1\r\norder: 3072\r\n---\r\nold body\r\n") try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: "") let after = try fixture.indexText(path) #expect(try body(of: fixture, path).isEmpty) #expect(after.contains("schema: 1\r\n"), "line endings are preserved per line, never normalized") #expect(after.contains("modified: ")) } @Test("No other file is opened, let alone rewritten, and no temp file is left behind") func siblingsAreUntouched() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let siblingBefore = try fixture.indexData(siblingPath) let laneBefore = try fixture.indexData(Ident.lane1) try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Only this card.\n") #expect(try fixture.indexData(siblingPath) == siblingBefore) #expect(try fixture.indexData(Ident.lane1) == laneBefore) // Hidden entries included — the writer's temps are dot-prefixed. #expect(try fixture.entryNames(cardPath) == ["index.md"]) } } // MARK: - Refusals @MainActor @Suite("BoardWriter ▸ writeBody refusals") struct WriteBodyRefusalTests { @Test("Frontmatter that cannot be edited in place refuses before the body is touched") func uneditableFrontmatterRefuses() throws { let fixture = try makeBoard() defer { fixture.tearDown() } // A whole-frontmatter flow mapping: readable, renderable, and unwritable — the settled // readable-but-uneditable rule, which a body edit is no exemption from, because the write // still has to stamp `modified` through the span editor. let path = "\(Ident.lane1)/\(Ident.card3)" try fixture.item(path, "---\n{schema: 1, order: 3072}\n---\nodd body\n") let before = try fixture.indexData(path) let error = writeFailure { try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: "new") } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(error?.operation == .editBody(title: nil), "the flow mapping's title is not addressable") #expect(try fixture.indexData(path) == before) } @Test("A folder that is not a lane or a card refuses") func strayFoldersRefuse() throws { let fixture = try makeBoard() defer { fixture.tearDown() } // The board root: its body is the board description, and no editor in the app opens it. let error = writeFailure { try BoardWriter.writeBody(inItemFolder: fixture.root, body: "nope") } if case .unreadable = error?.reason {} else { Issue.record("expected an unreadable refusal, got \(String(describing: error?.reason))") } } @Test("A failure names the card by the title the read found") func failuresNameTheCard() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let folder = fixture.url(cardPath) // Unwritable folder: the read and the parse both succeed, so the operation is enriched, and // then the atomic replace cannot land its temp file. try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path) let error = writeFailure { try BoardWriter.writeBody(inItemFolder: folder, body: "unwritable") } #expect(error?.operation == .editBody(title: "Notes")) #expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't save 'Notes'")) } } // MARK: - Through the store @MainActor @Suite("BoardStore ▸ writeCardBody") struct StoreWriteCardBodyTests { @Test("A save lands on disk and reports that it did") func theStoreWritesThrough() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Through the store.\n") #expect(outcome == .written) // Read back through the loader, never through the store's snapshot: the one-way flow means // the snapshot only catches up when the watcher's reload lands (02-architecture.md). #expect(try body(of: fixture, cardPath) == "Through the store.\n") } @Test("Saving what disk already says reports unchanged and writes nothing") func anEchoWritesNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexData(cardPath) #expect(store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: originalBody) == .unchanged) #expect(try fixture.indexData(cardPath) == before) } @Test("A trashed card is still written to, at its new .trash/ location") func aTrashedCardStillTakesTheFlush() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try BoardWriter.deleteCardToTrash(at: fixture.url(cardPath), inBoard: fixture.root) let store = try BoardStore(rootURL: fixture.root) let trashedPath = ".trash/\(Ident.card1)" // 05 ▸ Deletion & lifecycle, resettled 2026-07-28: "a dirty Edit buffer flushes into the // card's folder at its new `.trash/` location before the window dismisses ... so the // keystrokes survive a later restore". `BoardStore.cardBodyTarget` spans both containers for // exactly this, which is why the store finds the card by id with no hint of where it went. let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Typed as it went.\n") #expect(outcome == .written) #expect(try body(of: fixture, trashedPath) == "Typed as it went.\n") // Surgical: the write replaced the body span and nothing else, so the card is otherwise // exactly as the delete left it — a later restore brings the keystrokes back with it. #expect(try FrontmatterDocument.parse(fixture.indexText(trashedPath)).title == .valid("Notes")) #expect(try fixture.indexText(trashedPath).contains("project: lanework # agent overlay")) } @Test("A card that is not in the board at all reports vanished, and writes nowhere") func aVanishedCardWritesNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexData(cardPath) // "A card hard-deleted externally (folder gone) discards both — nowhere left to write" (05). #expect(store.writeCardBody(inCard: ItemID(rawValue: Ident.card4), body: "nowhere") == .vanished) #expect(try fixture.indexData(cardPath) == before) } @Test("A read-only board suspends the save rather than failing it") func theLockSuspends() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexData(cardPath) store.enterVanishedRootLock() // "Editor buffers kept but their debounced saves suspended" (02 § the lock's scope) — the // buffer's owner reads this as "hold the text", not as "the write failed". let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "held") #expect(outcome == .suspended(.vanishedRoot)) #expect(try fixture.indexData(cardPath) == before) #expect(store.banners.oneShots.isEmpty, "the lock's row is the message; a refused tick posts nothing") } @Test("A failed save reports the error, and the banner has it") func aFailedSaveReports() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) try FileManager.default.setAttributes( [.posixPermissions: 0o500], ofItemAtPath: fixture.url(cardPath).path ) let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "cannot land") guard case let .failed(error) = outcome else { Issue.record("expected a failure, got \(outcome)") return } #expect(error.operation == .editBody(title: "Notes")) // `performWrite` posts every `BoardWriteError` before it rethrows — the caller never has to // remember to, and a `try?` at a call site cannot make a failure silent. #expect(store.banners.oneShots.contains { BannerCenter.headline(for: $0.error).hasPrefix("Couldn't save 'Notes'") }) } }