Files
lanework/KanbanTests/RawSourceTests.swift
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

726 lines
31 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// The card window's raw-source outlet (05-card-window.md ▸ Raw source outlet) — the escape hatch
/// that "keeps *everything* — unknown keys, exotic formatting — reachable in-app".
///
/// Its promise is a byte one, so this suite reads bytes: what Apply writes must be the buffer's own
/// bytes and nothing else, which means **no `modified` stamp and no cleared `modified-by`** — the one
/// write in the app of which both are true (01-storage-format.md § Frontmatter settles each
/// explicitly). The negative half matters as much: a proposal the loader would refuse must leave the
/// file untouched, and "the loader" has to mean the same code the loader runs, not a second parser
/// that agrees with it today.
///
/// `WriterFixture`, `Ident`, `Item` and `writeFailure` come from `WriterTestSupport.swift`.
// MARK: - Fixture
/// The card the outlet opens, carrying everything a verbatim write must be able to preserve *and*
/// everything a stamping write would have destroyed: an unknown key with an inline comment, a second
/// unknown key in a shape the app never writes, an old `modified`, 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
---
# Notes
Some *prose*.
"""
/// A proposal in the shape only this outlet can produce: a hand-added key, a comment above it,
/// blank lines inside the frontmatter, an old `modified` left exactly as the user found it, a
/// `modified-by` they typed themselves — and **no final newline**, the byte a re-serializing writer
/// would have added back.
private let handEditedSource = """
---
schema: 1
title: Notes
# the user's own comment
sphere: work
project: lanework # agent overlay
labels: [a, b, c]
order: 1024
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: rzen
---
# Notes
Rewritten by hand.
"""
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
@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)
return fixture
}
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
}
/// Filesystem timestamps are coarse; a write inside the same tick would be invisible to an `mtime`
/// assertion either way it goes.
private func settleClock() {
Thread.sleep(forTimeInterval: 0.05)
}
// MARK: - Validation, through the loader's own parse
@MainActor
@Suite("Raw source ▸ validation")
struct RawSourceValidationTests {
@Test("A card file with unknown keys and exotic formatting is valid — that is the whole point")
func theOutletValidatesWhatItExistsToWrite() throws {
let document = try BoardLoader.validateCardIndex(Data(handEditedSource.utf8), path: "index.md")
#expect(document.title.value == "Notes")
// Unknown keys are not merely tolerated, they are the outlet's reason for being: "the write
// path for frontmatter the app doesn't own" (05 ▸ Details).
#expect(document.value(for: "sphere") == .string("work"))
}
/// **A card's `schema` and `order` are both optional** (01-storage-format.md § Frontmatter and
/// § Ordering, re-ruled 2026-07-31) — the outlet's gate is the loader's rule, so it moved with
/// it: a card applied without either lands at its lane's bottom, read as schema 1, and gains a
/// real rank on its next touch.
@Test("The optional fields are optional, with the loader's own rule")
func schemaAndOrderAreOptional() throws {
for text in ["---\ntitle: x\norder: 1\n---\nbody\n", "---\nschema: 1\ntitle: x\n---\nbody\n", "---\ntitle: x\n---\nbody\n"] {
#expect(throws: Never.self) {
try BoardLoader.validateCardIndex(Data(text.utf8), path: "index.md")
}
}
// What still refuses: a value that is there and unreadable, and a card from a newer app —
// which this one has no honest way to rewrite.
let malformed = validationFailure("---\nschema: one\norder: 1\n---\nbody\n")
#expect(malformed?.reason == .malformedSchema(raw: "one"))
let future = validationFailure("---\nschema: 99\norder: 1\n---\nbody\n")
#expect(future?.reason == .schemaNewerThanApp(found: 99))
}
@Test("Malformed YAML refuses with the loader's error, line number and all")
func malformedYAMLRefuses() {
let error = validationFailure("---\nschema: 1\norder: 1\n bad: [unclosed\n---\nbody\n")
guard case let .unparseableYAML(_, line) = error?.reason else {
Issue.record("expected unparseable YAML, got \(String(describing: error?.reason))")
return
}
// The taxonomy's display text is what the alert shows — "detailed alert" means this detail.
#expect(error?.reason.description.contains("unparseable YAML") == true)
#expect(line != nil, "the alert names the line the user has to go and look at")
}
@Test("A file with no frontmatter at all refuses")
func missingDelimitersRefuse() {
let error = validationFailure("just a body, no frontmatter\n")
#expect(error?.reason.description.contains("---") == true)
}
@Test("A BOM is refused — the same rejection the loader makes, not a second rule")
func aBOMIsRefused() {
var bommed = Data([0xEF, 0xBB, 0xBF])
bommed.append(Data("---\nschema: 1\norder: 1\n---\nbody\n".utf8))
let error = validationFailureData(bommed)
// "A BOM'd file fails the frontmatter delimiter and is rejected the same way, deliberately"
// (01-storage-format.md § Fractal layout ▸ Rules) — so the refusal is the delimiter's, which
// is exactly what proves the outlet is not re-implementing the encoding rules.
#expect(error?.reason.description.contains("---") == true)
}
@Test("Bytes that are not UTF-8 are refused before anything tries to parse them")
func invalidUTF8IsRefused() {
var invalid = Data("---\nschema: 1\norder: 1\ntitle: ".utf8)
invalid.append(contentsOf: [0xFF, 0xFE, 0x80])
invalid.append(Data("\n---\nbody\n".utf8))
let error = validationFailureData(invalid)
#expect(error?.reason == .unparseableYAML(message: "file is not UTF-8", line: nil))
}
@Test("A flow-mapping frontmatter is valid here, though no other write in the app may touch it")
func theUneditableShapeIsWritableAsSource() throws {
// `BoardWriter.writeBody` refuses this shape — a surgical span edit of it cannot be
// expressed. Raw source replaces the whole file, so it is precisely the surface that can get
// a user *out* of such a file. Nothing here checks `uneditableShape`, deliberately.
let document = try BoardLoader.validateCardIndex(
Data("---\n{schema: 1, order: 1024, title: Odd}\n---\nodd body\n".utf8),
path: "index.md"
)
#expect(document.uneditableShape != nil, "still uneditable in place — and still writable as source")
}
private func validationFailure(_ text: String) -> BoardLoadError? {
validationFailureData(Data(text.utf8))
}
private func validationFailureData(_ data: Data) -> BoardLoadError? {
do {
_ = try BoardLoader.validateCardIndex(data, path: "index.md")
Issue.record("expected the validation to refuse, but it passed")
return nil
} catch {
return error
}
}
}
// MARK: - The Writer
@MainActor
@Suite("BoardWriter ▸ raw source")
struct RawSourceWriterTests {
@Test("Reading gives the file's literal text — comments, CRLF, missing final newline and all")
func theReadIsByteHonest() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
// No final newline, mixed line endings, a comment: everything a round-trip through a parser
// might tidy away.
let odd = "---\r\nschema: 1\r\norder: 1024\n# comment\ntitle: Odd\r\n---\r\nbody with no final newline"
try fixture.item(cardPath, odd)
#expect(try BoardWriter.readRawSource(ofCard: fixture.url(cardPath)) == odd)
}
@Test("A file that is not UTF-8 refuses to open as source rather than opening a lie")
func nonUTF8RefusesToOpen() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
var bytes = Data("---\nschema: 1\norder: 1024\ntitle: ".utf8)
bytes.append(contentsOf: [0xFF, 0xFE])
bytes.append(Data("\n---\nbody\n".utf8))
try fixture.item(cardPath, bytes: bytes)
var thrown: BoardWriteError?
do {
_ = try BoardWriter.readRawSource(ofCard: fixture.url(cardPath))
Issue.record("expected the read to refuse")
} catch {
thrown = error
}
#expect(thrown?.reason == .unreadable(message: "file is not UTF-8"))
}
@Test("Only a card's file is reachable — not a lane's, not the board's")
func onlyCardsAreReachable() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
for folder in [fixture.root, fixture.url(Ident.lane1)] {
let error = writeFailure { try BoardWriter.writeRawSource(inCard: folder, text: editableCard) }
if case .unreadable = error?.reason {} else {
Issue.record("expected an unreadable refusal, got \(String(describing: error?.reason))")
}
}
}
@Test("Apply writes the buffer's bytes exactly — no stamp, no cleared modified-by, no added newline")
func applyIsByteForByte() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let wrote = try BoardWriter.writeRawSource(inCard: fixture.url(cardPath), text: handEditedSource)
#expect(wrote)
// The whole contract in one assertion: what is on disk *is* the proposal.
#expect(try fixture.indexData(cardPath) == Data(handEditedSource.utf8))
// And the three things a composed app write would have done to it, spelled out because each
// is settled prose rather than an implementation detail:
let after = try fixture.indexText(cardPath)
// 1. `modified` untouched — "Two designed app writes therefore don't bump it, deliberately:
// **raw-source Apply** writes the validated buffer byte-for-byte (the verbatim contract
// outranks stamping)" (01-storage-format.md § Frontmatter).
#expect(after.contains("modified: 2026-02-02T09:00:00Z"))
// 2. `modified-by` kept — "One carve-out: **raw-source Apply** … writes byte-for-byte and does
// *not* clear a stamp the user typed or kept" (01), and 05 from the other side.
#expect(after.contains("modified-by: rzen"))
// 3. No final newline invented.
#expect(!after.hasSuffix("\n"))
}
@Test("Byte-for-byte still means the file was rewritten — mtime moves, content is the proposal")
func aRealApplyTouchesTheFile() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try modificationDate(fixture, cardPath)
settleClock()
try BoardWriter.writeRawSource(inCard: fixture.url(cardPath), text: handEditedSource)
// The stamping choice is about *content*: `modified` describes the item, `mtime` describes
// the file, and the file genuinely changed. Pinning both directions is what keeps a future
// "helpful" stamp from passing the byte assertion by luck.
#expect(try modificationDate(fixture, cardPath) != before)
#expect(try fixture.indexData(cardPath) == Data(handEditedSource.utf8))
}
@Test("Applying the text the file already has writes nothing at all — bytes and mtime")
func anUnchangedBufferIsNeverReSerialized() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.indexData(cardPath)
let mtime = try modificationDate(fixture, cardPath)
settleClock()
let wrote = try BoardWriter.writeRawSource(inCard: fixture.url(cardPath), text: editableCard)
#expect(!wrote, "Apply on a buffer that was only read must not churn the file")
#expect(try fixture.indexData(cardPath) == before)
#expect(try modificationDate(fixture, cardPath) == mtime)
}
@Test("A proposal that would not load is refused, and the file keeps every byte it had")
func aRefusedProposalWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.indexData(cardPath)
let mtime = try modificationDate(fixture, cardPath)
settleClock()
let error = writeFailure {
try BoardWriter.writeRawSource(
inCard: fixture.url(cardPath),
text: "---\nschema: 1\n order: [unclosed\n---\nbody\n"
)
}
guard case let .invalidSource(loadError) = error?.reason else {
Issue.record("expected an invalid-source refusal, got \(String(describing: error?.reason))")
return
}
if case .unparseableYAML = loadError.reason {} else {
Issue.record("expected the loader's own parse error, got \(loadError.reason)")
}
#expect(try fixture.indexData(cardPath) == before)
#expect(try modificationDate(fixture, cardPath) == mtime)
}
@Test("A BOM'd proposal is refused by the same rule, and writes nothing")
func aBOMdProposalWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.indexData(cardPath)
let error = writeFailure {
try BoardWriter.writeRawSource(inCard: fixture.url(cardPath), text: "\u{FEFF}" + editableCard)
}
if case .invalidSource = error?.reason {} else {
Issue.record("expected an invalid-source refusal, got \(String(describing: error?.reason))")
}
#expect(try fixture.indexData(cardPath) == before)
}
@Test("A failure names the card by the title the file still carries")
func failuresNameTheCard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let folder = fixture.url(cardPath)
// Unwritable folder: the read, the title enrichment and the validation all succeed, and then
// the atomic replace cannot land its temp file.
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path)
let error = writeFailure {
try BoardWriter.writeRawSource(inCard: folder, text: handEditedSource)
}
// The title on the window — read off disk, never off the buffer, which may propose a name
// that never landed (`WriteOperation.rawSource`).
#expect(error?.operation == .rawSource(title: "Notes"))
#expect(
BannerCenter.headline(for: try #require(error))
.hasPrefix("Couldn't apply source changes to 'Notes'")
)
}
@Test("A flow-mapping card — unwritable by every other path — is rewritable as source")
func theEscapeHatchEscapes() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let path = "\(Ident.lane1)/\(Ident.card3)"
try fixture.item(path, "---\n{schema: 1, order: 3072, title: Odd}\n---\nodd body\n")
// `writeBody` refuses this file outright; the outlet is how a user gets out of it.
#expect(writeFailure { try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: "new") } != nil)
let repaired = "---\nschema: 1\norder: 3072\ntitle: Odd\n---\nodd body\n"
try BoardWriter.writeRawSource(inCard: fixture.url(path), text: repaired)
#expect(try fixture.indexData(path) == Data(repaired.utf8))
}
}
// MARK: - Through the store
@MainActor
@Suite("BoardStore ▸ raw source")
struct RawSourceStoreTests {
@Test("Reading gives the file, and Apply puts the buffer on disk verbatim")
func theStoreReadsAndApplies() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let card = ItemID(rawValue: Ident.card1)
#expect(store.readCardSource(inCard: card) == .read(editableCard))
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .applied)
// Read back off disk, never through the snapshot: the one-way flow means the snapshot only
// catches up when the watcher's reload lands (02-architecture.md).
#expect(try fixture.indexData(cardPath) == Data(handEditedSource.utf8))
}
@Test("Applying the file's own text reports unchanged and writes nothing")
func anUnchangedApplyWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(cardPath)
let mtime = try modificationDate(fixture, cardPath)
settleClock()
#expect(store.applyCardSource(inCard: ItemID(rawValue: Ident.card1), text: editableCard) == .unchanged)
#expect(try fixture.indexData(cardPath) == before)
#expect(try modificationDate(fixture, cardPath) == mtime)
}
@Test("An invalid buffer is refused before any bracket opens — no write, no banner")
func anInvalidBufferPostsNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(cardPath)
let outcome = store.applyCardSource(
inCard: ItemID(rawValue: Ident.card1),
text: "---\nschema: unreadable\norder: 1\n---\nbody\n"
)
#expect(outcome == .invalid(BoardLoadError(path: "index.md", reason: .malformedSchema(raw: "unreadable"))))
#expect(try fixture.indexData(cardPath) == before)
// The alert is the surfacing for this one — a banner as well would say the same thing twice,
// and a write that never started is not a failed write.
#expect(store.banners.oneShots.isEmpty)
}
@Test("A tombstoned card takes no Apply — a stale buffer never undeletes a card")
func aTombstonedCardIsRefused() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.move(cardPath, toTrash: Ident.card1)
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(".trash/\(Ident.card1)")
let card = ItemID(rawValue: Ident.card1)
// 05 ▸ Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply would
// write a whole stale `index.md` over the trashed card — a delete is never fought by a stale
// buffer." The Edit buffer's flush is the opposite rule, deliberately, which is why the two
// walks differ (`boardItem` vs `cardBodyTarget`).
#expect(store.readCardSource(inCard: card) == .vanished)
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .vanished)
#expect(try fixture.indexData(".trash/\(Ident.card1)") == before)
}
@Test("A card that is not in the board at all is vanished too")
func anAbsentCardIsVanished() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.readCardSource(inCard: ItemID(rawValue: Ident.card4)) == .vanished)
#expect(store.applyCardSource(inCard: ItemID(rawValue: Ident.card4), text: handEditedSource) == .vanished)
}
@Test("A read-only board suspends the Apply 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)
let card = ItemID(rawValue: Ident.card1)
store.enterVanishedRootLock()
// Apply is a mutation, however literal, so the lock refuses it like every other write — and
// the buffer's owner reads this as "hold the text", the standing lock row being the message.
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .suspended(.vanishedRoot))
#expect(try fixture.indexData(cardPath) == before)
#expect(store.banners.oneShots.isEmpty, "the lock's row is the message; a refused Apply posts nothing")
// Reading is not a write: a locked board still opens its source, which is how the text gets
// copied out (02-architecture.md § the lock's scope).
#expect(store.readCardSource(inCard: card) == .read(editableCard))
}
@Test("A failed write reports the error, and the banner has it")
func aFailedApplyReports() 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.applyCardSource(inCard: ItemID(rawValue: Ident.card1), text: handEditedSource)
guard case let .failed(error) = outcome else {
Issue.record("expected a failure, got \(outcome)")
return
}
#expect(error.operation == .rawSource(title: "Notes"))
#expect(store.banners.oneShots.contains {
BannerCenter.headline(for: $0.error).hasPrefix("Couldn't apply source changes to 'Notes'")
})
}
}
// MARK: - The session
/// The outlet's state machine, driven through **the window's own wiring**
/// (`CardWindowHost.configureRawSource`) rather than a re-typed copy of it: the ordering it encodes —
/// flush, *then* read fresh — is invisible in a running window until it is wrong.
@MainActor
@Suite("Card raw source ▸ session")
struct RawSourceSessionTests {
/// Everything a card window holds, wired exactly as the host wires it.
private struct Rig {
let fixture: WriterFixture
let store: BoardStore
let presentation: CardBodyPresentation
let body: CardBodyEditSession
let raw: CardRawSourceSession
}
private func makeRig() throws -> Rig {
let fixture = try makeBoard()
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
let presentation = CardBodyPresentation()
let body = CardBodyEditSession()
body.save = { [weak store] text in
guard let store else { return .vanished }
return store.writeCardBody(inCard: cardID, body: text)
}
body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
presentation.flushEdits = { body.endEditSession() }
let raw = CardRawSourceSession()
CardWindowHost.configureRawSource(raw, body: body, presentation: presentation, store: store, cardID: cardID)
return Rig(fixture: fixture, store: store, presentation: presentation, body: body, raw: raw)
}
@Test("Entering flushes the Edit buffer first, then reads what the flush put on disk")
func enteringFlushesThenReadsFresh() throws {
let rig = try makeRig()
defer { rig.fixture.tearDown() }
rig.presentation.setMode(.edit)
// Unsaved keystrokes, well inside the debounce: nothing has reached disk yet.
rig.body.edited("# Notes\n\nTyped but not yet saved.\n")
#expect(rig.body.isDirty)
#expect(!(try rig.fixture.indexText(cardPath).contains("not yet saved")))
#expect(rig.raw.enter())
// The ordering, both halves. If the read had run first the editor would show a file the app
// was a moment from overwriting from a buffer the user could no longer see.
#expect(try rig.fixture.indexText(cardPath).contains("Typed but not yet saved."))
#expect(rig.raw.text.contains("Typed but not yet saved."))
#expect(rig.raw.text == (try rig.fixture.indexText(cardPath)), "the buffer is the file, byte for byte")
#expect(!rig.body.isDirty)
}
@Test("Entering from Edit leaves the body column in Preview — and leaving raw source lands there")
func rawSourceExitsToPreview() throws {
let rig = try makeRig()
defer { rig.fixture.tearDown() }
rig.presentation.setMode(.edit)
rig.raw.enter()
// 05 lists "raw-source entry" among the three events that *leave Edit*, so entering genuinely
// leaves it and Preview — the resting state — is what the exit reveals. Recorded as a choice
// because 05 does not name the exit mode itself.
#expect(rig.presentation.mode == .preview)
rig.raw.cancel()
#expect(rig.presentation.mode == .preview)
#expect(!rig.raw.isActive)
}
@Test("An empty body after Apply does not drag the window into Edit")
func theOpeningRuleDoesNotRunAgain() throws {
let rig = try makeRig()
defer { rig.fixture.tearDown() }
// The window opened on a card with a body, so it opened in Preview — once, per the rule.
#expect(rig.presentation.openIfNeeded(body: "# Notes\n") == .preview)
rig.raw.enter()
// The user deletes the body in source mode and applies.
rig.raw.text = "---\nschema: 1\ntitle: Notes\norder: 1024\n---\n"
#expect(rig.raw.applyAndLeave())
// "The rule is about *opening* a card" (`CardBodyPresentation.openIfNeeded`), and a raw exit
// is not an open: the user gets the blank Preview they just wrote, and ⌘E.
#expect(rig.presentation.mode == .preview)
#expect(try FrontmatterDocument.parse(rig.fixture.indexText(cardPath)).body.isEmpty)
}
@Test("Apply closes the outlet and lands the bytes")
func applyCommitsAndLeaves() throws {
let rig = try makeRig()
defer { rig.fixture.tearDown() }
rig.raw.enter()
rig.raw.text = handEditedSource
#expect(rig.raw.applyAndLeave())
#expect(!rig.raw.isActive)
#expect(rig.raw.alert == nil)
#expect(try rig.fixture.indexData(cardPath) == Data(handEditedSource.utf8))
}
@Test("A failed validation keeps source mode open, with the alert and the text")
func aFailedValidationStays() throws {
let rig = try makeRig()
defer { rig.fixture.tearDown() }
let before = try rig.fixture.indexData(cardPath)
rig.raw.enter()
let broken = "---\nschema: 1\n order: [unclosed\n---\nbody\n"
rig.raw.text = broken
#expect(!rig.raw.applyAndLeave())
// "A failed validation keeps source mode open (toggle stays checked) with the alert" (05).
#expect(rig.raw.isActive, "the toggle stays checked")
#expect(rig.raw.text == broken, "and the user's text is still in front of them")
guard case .invalid = rig.raw.alert else {
Issue.record("expected a validation alert, got \(String(describing: rig.raw.alert))")
return
}
#expect(try rig.fixture.indexData(cardPath) == before)
// OK returns to editing the raw text — nothing else moves.
rig.raw.dismissAlert()
#expect(rig.raw.alert == nil)
#expect(rig.raw.isActive)
#expect(rig.raw.text == broken)
}
@Test("Cancel discards without ceremony — nothing written, nothing asked")
func cancelDiscards() throws {
let rig = try makeRig()
defer { rig.fixture.tearDown() }
let before = try rig.fixture.indexData(cardPath)
rig.raw.enter()
rig.raw.text = handEditedSource
rig.raw.cancel()
#expect(!rig.raw.isActive)
#expect(rig.raw.applyAttempts == 0, "Cancel is not a write that happened to fail — it never asked")
#expect(try rig.fixture.indexData(cardPath) == before)
#expect(rig.raw.text.isEmpty, "and the buffer is gone with it, so a re-entry reads disk afresh")
}
@Test("A file that cannot be read does not open source mode")
func anUnreadableFileDoesNotOpen() throws {
let rig = try makeRig()
defer { rig.fixture.tearDown() }
var bytes = Data("---\nschema: 1\norder: 1024\ntitle: ".utf8)
bytes.append(contentsOf: [0xFF, 0xFE])
bytes.append(Data("\n---\nbody\n".utf8))
try rig.fixture.item(cardPath, bytes: bytes)
#expect(!rig.raw.enter())
// Settled here, 05 being silent: an editor over lossily-decoded bytes would make the outlet's
// one promise false, since Apply writes back whatever is in it.
#expect(!rig.raw.isActive, "the toggle stays unchecked")
guard case .unreadable = rig.raw.alert else {
Issue.record("expected an unreadable alert, got \(String(describing: rig.raw.alert))")
return
}
}
@Test("A window that has not joined its board opens nothing and writes nothing")
func unwiredSeamsFailClosed() {
let raw = CardRawSourceSession()
#expect(!raw.enter())
#expect(!raw.isActive)
#expect(!raw.applyAndLeave(), "and an Apply with no outlet open is not an Apply")
}
@Test("Re-entering after an Apply reads disk again rather than reusing the old buffer")
func reEntryReadsFresh() throws {
let rig = try makeRig()
defer { rig.fixture.tearDown() }
rig.raw.enter()
rig.raw.text = handEditedSource
rig.raw.applyAndLeave()
// Somebody else — an agent, a pull — rewrites the card while no window holds it.
let foreign = handEditedSource.replacingOccurrences(of: "Rewritten by hand.", with: "Rewritten by them.")
try rig.fixture.item(cardPath, foreign)
rig.raw.enter()
#expect(rig.raw.text == foreign, "the outlet never shows an in-memory snapshot of the file")
}
}
// MARK: - The menu rows
@MainActor
@Suite("Card view commands ▸ validation")
struct CardViewCommandValidationTests {
@Test("Edit Body needs a card window, and disables while source mode is active")
func editBodyDisablesUnderRawSource() {
let presentation = CardBodyPresentation()
let raw = CardRawSourceSession()
raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") }
raw.apply = { _ in .applied }
// No card window in front: scope alone disables the row.
#expect(!EditBodyCommand.isEnabled(body: nil, rawSource: nil))
#expect(!EditBodyCommand.isEnabled(body: nil, rawSource: raw))
// A card window, not in source mode: live.
#expect(EditBodyCommand.isEnabled(body: presentation, rawSource: raw))
raw.enter()
// "View ▸ Edit Body (⌘E) disables while source mode is active, matching its toolbar item"
// (05 ▸ Raw source outlet) — the two would otherwise be editing the same bytes from two
// surfaces, one of which is not on screen.
#expect(raw.isActive)
#expect(!EditBodyCommand.isEnabled(body: presentation, rawSource: raw))
raw.cancel()
#expect(EditBodyCommand.isEnabled(body: presentation, rawSource: raw))
}
}