Phase 3 finishes the pivot at the surface. One card face serves two containers: CardFaceView extracted with a role — board or trash — so stripe, tint, chip, selection stroke, cut dim, marquee registration, and drag are shared by construction, the trash side differing only in its absences: no Open, no rename, no Style, no file-hover highlight, and a Delete that goes through the confirmation host. The column rewrote around the lanes' own single-column masonry so drag reflow reads as positional slides; chrome stays the hatched header, symbol, and count — 11 gives Empty Trash to the File menu alone. Two real grammar bugs die here: plain Backspace on a trash selection purged without the confirmation the menu raises, and the context menu's Delete resolved against the standing selection, so right-clicking a trash card under a board selection silently did nothing — it now stages the clicked set explicitly. Open, Rename, Style, and Empty Trash validation became testable store seams; the column is one named accessibility container of ordinary card elements. The tombstone era is swept: deleteItem, restoreItem, stripTombstonedChildren — dead since lane copies stopped nesting trash — the restore verb, the unreachable put-back banner row, and every quasi-lane doc comment. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
339 lines
15 KiB
Swift
339 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 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, order: 1024)
|
|
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'") })
|
|
}
|
|
}
|