Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
719 lines
31 KiB
Swift
719 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"))
|
|
}
|
|
|
|
@Test("The two required fields are required, with the loader's own words")
|
|
func schemaAndOrderAreRequired() {
|
|
let noSchema = validationFailure("---\ntitle: x\norder: 1\n---\nbody\n")
|
|
#expect(noSchema?.reason == .missingSchema)
|
|
|
|
let noOrder = validationFailure("---\nschema: 1\ntitle: x\n---\nbody\n")
|
|
#expect(noOrder?.reason == .missingOrder)
|
|
|
|
// The same fail-fast rule the loader applies at load: a card from a newer app is not
|
|
// something this one may rewrite.
|
|
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: "---\ntitle: no schema here\norder: 1\n---\nbody\n"
|
|
)
|
|
|
|
#expect(outcome == .invalid(BoardLoadError(path: "index.md", reason: .missingSchema)))
|
|
#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))
|
|
}
|
|
}
|