The editing surface: the same hosted TextKit-1 text view gains an editable branch with a per-keystroke line-scanner highlighter — chosen over a parser re-parse because a mid-typing buffer is usually invalid Markdown and 05 wants the delimiters themselves dimmed; apply only sets attributes, so presentation-never-transforms is structural. Saves ride a ~700ms injectable debounce through BoardWriter.writeBody — toggleTaskMarker's idiom widened to the body span, frontmatter bytes untouched, refusing to write when disk already holds that body, which enforces all three gates (untouched, reverted, echo) at the layer that owns the bytes with one isDirty predicate above it. Mode grammar lands whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape returns, and every flip flushes first; window close flushes through the existing retry/save-copy/discard modal, and the dismissal flush deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always follows the snapshot, the buffer only when clean, both surfaces render the buffer. Undo is the editor's own session-scoped NSUndoManager; endEditSession names the pro-m1 one-commit-per-session boundary. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
335 lines
15 KiB
Swift
335 lines
15 KiB
Swift
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 tombstoned card is still written to, and stays tombstoned")
|
|
func aTombstonedCardStillTakesTheFlush() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try BoardWriter.deleteItem(at: fixture.url(cardPath))
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// 05 ▸ Deletion & lifecycle: "a dirty Edit buffer flushes into the tombstoned card's folder
|
|
// before the window dismisses ... so the keystrokes survive Put Back".
|
|
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Typed as it went.\n")
|
|
|
|
#expect(outcome == .written)
|
|
#expect(try body(of: fixture, cardPath) == "Typed as it went.\n")
|
|
// Surgical: the write replaced the body span, so the tombstone is still standing and Put
|
|
// Back still has something to put back.
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
|
|
}
|
|
|
|
@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'") })
|
|
}
|
|
}
|