The trash sorts by modified descending — the arrival rank mint retires (Ranks.isOrderedForTrash one comparator, loader + merged order agree; the legacy deleted: migration stamps modified from the tombstone timestamp where parseable; delete undo steps validate existence-only; agent guide v8). Trash selection goes kind-blind — ranges, marquee, Select All, and the successor walk sweep both kinds; the guard moves to the exits (mixed-payload drop refusal, copy/cut validation). The copy stamping preflight widens back to comment depth (load-scoped posture — the board always loads, the gesture refuses whole). Fixes a latent no-op: trashed-lane drag restore never fired (DragSession.beginLanes hard-coded the board container). 2403 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
639 lines
34 KiB
Swift
639 lines
34 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// The executable spec for the write path's fidelity guarantees — the m2 "write-path fidelity
|
||
/// test suite" card. `BoardWriterTests.swift` already pins per-operation behavior file by file
|
||
/// (one suite per `BoardWriter` call); this file pins the guarantees that cut *across* calls and
|
||
/// across the whole tree, the properties no single per-operation test is positioned to state.
|
||
///
|
||
/// Four of the card's six guarantees are already fully pinned in `BoardWriterTests.swift` and are
|
||
/// not repeated here — only referenced:
|
||
///
|
||
/// - **Byte-identical body round-trip**, by direct byte comparison —
|
||
/// `BoardWriterPreservationTests.aFrontmatterEditLeavesTheBodyByteIdentical`.
|
||
/// - **Atomicity**: temp-file+rename leaves no residue on success —
|
||
/// `BoardWriterPreservationTests.aSuccessfulWriteLeavesNoTempFileBehind`; an injected mid-write
|
||
/// failure (an unwritable parent folder) leaves the original bytes *and* the directory listing
|
||
/// untouched, temp file included — `BoardWriterFailureTests
|
||
/// .aFailedWriteLeavesTheFileAndTheFolderExactlyAsTheyWere`. Audited: both halves of "verifiable
|
||
/// temp-file+rename, nothing left behind either way" already exist; there is no missing piece.
|
||
/// - **Move/copy identity** — UUID preservation, fresh minting, and the import-boundary collision
|
||
/// repair, including the per-folder compound case (a lane arriving with one colliding card stays
|
||
/// a lane *move*) — `BoardWriterMoveTests`/`BoardWriterCopyTests` in full, in particular
|
||
/// `aLaneMoveRemintsOnlyTheCollidingCard`.
|
||
///
|
||
/// What follows fills the remaining gaps: minimal-touch stated with **mtimes**, not just bytes
|
||
/// (existing sibling-byte assertions never look at the filesystem's own "did this file move"
|
||
/// signal); the renumber fallback stated as the *positive* exception across a whole board rather
|
||
/// than within one lane; unknown-key order through a writer op
|
||
/// with keys deliberately interleaved among schema-owned ones (existing coverage groups the
|
||
/// unknown keys together); and one end-to-end composite scenario tying every guarantee together.
|
||
|
||
// MARK: - Shared helpers
|
||
|
||
/// A file's bytes and on-disk modification date — the two facts "minimal touch" promises stay
|
||
/// put for every `index.md` a mutation does not target.
|
||
private func snapshot(_ fixture: WriterFixture, _ relativePath: String) throws -> (data: Data, modified: Date) {
|
||
let indexURL = fixture.url(relativePath).appendingPathComponent("index.md")
|
||
let data = try Data(contentsOf: indexURL)
|
||
let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path)
|
||
guard let modified = attributes[.modificationDate] as? Date else {
|
||
Issue.record("no modification date for \(relativePath)")
|
||
return (data, .distantPast)
|
||
}
|
||
return (data, modified)
|
||
}
|
||
|
||
/// Every folder under the fixture root holding an `index.md`, relative to the root (`""` for the
|
||
/// root itself) — recomputed on demand since `createCard`/`createLane` grow the set mid-test.
|
||
private func allIndexFolders(_ fixture: WriterFixture) -> [String] {
|
||
guard let walker = FileManager.default.enumerator(
|
||
at: fixture.root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
|
||
) else { return [] }
|
||
let rootPath = fixture.root.standardizedFileURL.path
|
||
var folders: [String] = []
|
||
for case let url as URL in walker where url.lastPathComponent == "index.md" {
|
||
let folderPath = url.deletingLastPathComponent().standardizedFileURL.path
|
||
if folderPath == rootPath {
|
||
folders.append("")
|
||
} else if folderPath.hasPrefix(rootPath + "/") {
|
||
folders.append(String(folderPath.dropFirst(rootPath.count + 1)))
|
||
}
|
||
}
|
||
return folders.sorted()
|
||
}
|
||
|
||
/// The file's lines minus every line that opens one of `keys` — a local twin of
|
||
/// `BoardWriterTests.swift`'s file-private `lines(of:excludingKeys:)`; each file keeps its own
|
||
/// since the declaration is `private` (file-scoped) and this file's fixtures are its own.
|
||
private func lines(of text: String, excludingKeys keys: [String]) -> [String] {
|
||
text.components(separatedBy: "\n")
|
||
.filter { line in !keys.contains { line.hasPrefix("\($0):") } }
|
||
}
|
||
|
||
/// A couple of extra literal identities beyond `Ident`'s: this file's boards run wider (2×2
|
||
/// grids, a renumber lane with an "other lane" alongside it) than `Ident`'s four lane/card slots
|
||
/// cover on their own.
|
||
private enum MoreIdent {
|
||
static let card5 = "aaaaaaaa-1111-4111-8111-111111111112"
|
||
}
|
||
|
||
// MARK: - Minimal touch, with mtimes
|
||
|
||
/// 01-storage-format.md § Ordering, "a reorder rewrites only the moved item's `index.md`", and §
|
||
/// Fractal layout ▸ Rules' round-trip guarantee — the mtime half. A same-content rewrite is
|
||
/// invisible to a byte comparison but not to the filesystem, so "untouched" has to mean both.
|
||
struct WriteFidelityMinimalTouchTests {
|
||
/// One realistic editing session — reorder, style, rename, delete, restore, create, import —
|
||
/// run back to back on a 2-lane × 2-card board. After each step, every `index.md` the step
|
||
/// did not target is asserted byte-identical *and* mtime-identical to its state just before
|
||
/// that step; the targeted file is asserted to have actually changed (proof the snapshot
|
||
/// method is sensitive to a real edit, not vacuously passing). The last step — an attachment
|
||
/// import — targets *nothing*: `importAttachments` never opens an `index.md` at all, so even
|
||
/// the host card's own frontmatter must come through unmoved.
|
||
@Test func aSequenceOfEverydayMutationsLeavesEveryOtherIndexByteAndMtimeIdentical() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Card Three"))
|
||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Card Four"))
|
||
|
||
var snapshots: [String: (data: Data, modified: Date)] = [:]
|
||
for folder in allIndexFolders(fixture) {
|
||
snapshots[folder] = try snapshot(fixture, folder)
|
||
}
|
||
|
||
/// `departed` names folders the step moved *out of the enumerated tree* — which, since
|
||
/// `allIndexFolders` skips hidden folders, is exactly what a delete now is: the card's folder
|
||
/// travels into `<root>/.trash/` (03-board-ui.md § Trash, resettled 2026-07-28) and stops
|
||
/// being visible here. They leave the tracked set rather than being asserted about, because
|
||
/// "untouched" is a claim about the files that stayed.
|
||
func step(
|
||
_ label: String,
|
||
targeting targets: Set<String>,
|
||
departed: Set<String> = [],
|
||
_ operation: () throws -> Void
|
||
) throws {
|
||
try operation()
|
||
for folder in departed { snapshots[folder] = nil }
|
||
|
||
for (folder, before) in snapshots where !targets.contains(folder) {
|
||
let after = try snapshot(fixture, folder)
|
||
#expect(after.data == before.data, "\(label) touched \(folder)'s bytes")
|
||
#expect(after.modified == before.modified, "\(label) touched \(folder)'s mtime")
|
||
}
|
||
for target in targets {
|
||
let before = try #require(snapshots[target])
|
||
let after = try snapshot(fixture, target)
|
||
#expect(after.data != before.data, "\(label) left \(target) byte-identical")
|
||
snapshots[target] = after
|
||
}
|
||
for folder in allIndexFolders(fixture) where snapshots[folder] == nil {
|
||
snapshots[folder] = try snapshot(fixture, folder)
|
||
}
|
||
}
|
||
|
||
try step("reorder", targeting: ["\(Ident.lane1)/\(Ident.card1)"]) {
|
||
try BoardWriter.updateIndex(
|
||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"), operation: .style(title: nil)
|
||
) { $0.set(FrontmatterKeys.order, to: .double(1536)) }
|
||
}
|
||
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
||
try BoardWriter.updateIndex(
|
||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil)
|
||
) { $0.set(FrontmatterKeys.background, to: .string("blue")) }
|
||
}
|
||
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
||
try BoardWriter.updateIndex(
|
||
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: .style(title: nil)
|
||
) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) }
|
||
}
|
||
// The delete is a **move** into `.trash/` (03-board-ui.md § Trash): the card's folder leaves
|
||
// the visible tree entirely, and every index that stayed behind must be untouched — mtime
|
||
// included, which is the point of this harness.
|
||
try step("delete", targeting: [], departed: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
||
try BoardWriter.deleteCardToTrash(
|
||
at: fixture.url("\(Ident.lane2)/\(Ident.card4)"), inBoard: fixture.root
|
||
)
|
||
}
|
||
// The restore is an ordinary move out — "there is no restore-specific machinery and no Put
|
||
// Back" (03 § Trash) — so the folder simply comes back, and again nothing else moves.
|
||
try step("restore", targeting: []) {
|
||
_ = try BoardWriter.moveItem(
|
||
at: BoardWriter.trashFolder(inBoard: fixture.root).appendingPathComponent(Ident.card4),
|
||
toParent: fixture.url(Ident.lane2),
|
||
sourceBoardRoot: fixture.root,
|
||
destinationBoardRoot: fixture.root,
|
||
order: 2048
|
||
)
|
||
}
|
||
|
||
var createdCard = ""
|
||
try step("create card", targeting: []) {
|
||
createdCard = try BoardWriter.createCard(inLane: fixture.url(Ident.lane1), title: "Fresh").rawValue
|
||
}
|
||
#expect(BoardLoader.isUUIDShaped(createdCard))
|
||
|
||
// A **lane** delete is the same move, one level up (03 § Trash, re-ruled 2026-07-29): the
|
||
// lane's folder leaves the visible tree carrying its whole subtree, and the cards inside it
|
||
// are neither read nor rewritten — their mtimes prove it, since they are still tracked here
|
||
// under their new path only after the restore puts the lane back.
|
||
try step(
|
||
"delete lane",
|
||
targeting: [],
|
||
departed: [Ident.lane2, "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(Ident.card4)"]
|
||
) {
|
||
try BoardWriter.deleteLaneToTrash(
|
||
at: fixture.url(Ident.lane2), inBoard: fixture.root
|
||
)
|
||
}
|
||
try step("restore lane", targeting: []) {
|
||
_ = try BoardWriter.moveItem(
|
||
at: BoardWriter.trashFolder(inBoard: fixture.root).appendingPathComponent(Ident.lane2),
|
||
toParent: fixture.root,
|
||
sourceBoardRoot: fixture.root,
|
||
destinationBoardRoot: fixture.root,
|
||
order: 2048
|
||
)
|
||
}
|
||
|
||
try step("import attachment", targeting: []) {
|
||
// Dot-prefixed so it never becomes a board-root stray the loader has to warn about
|
||
// — the source lives outside the board tree in spirit, just not in path.
|
||
let source = try fixture.file(".sources/shot.png", Data([0x01, 0x02]))
|
||
_ = try BoardWriter.importAttachments([source], intoCard: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||
}
|
||
|
||
#expect(try BoardLoader.load(boardRoot: fixture.root).warnings.isEmpty)
|
||
}
|
||
}
|
||
|
||
// MARK: - The renumber exception, stated positively
|
||
|
||
/// 01-storage-format.md § Ordering, "the one exception to the only-the-moved-item rule":
|
||
/// `renumberVisibleChildren` rewrites *every* visible sibling in the lane it renumbers — bytes
|
||
/// and mtimes both move — while everything outside that lane, tombstones inside it included,
|
||
/// stays put. `BoardWriterRenumberTests` already pins the within-lane byte half (tombstones and
|
||
/// strays untouched); this test widens the lens to the whole board and adds the mtime half on
|
||
/// both sides of the exception.
|
||
struct WriteFidelityRenumberTests {
|
||
@Test func renumberRewritesTheWholeLaneButLeavesEverythingElseByteAndMtimeIdentical() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1.0000003", title: "A"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1.0000001", title: "B"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "1.0000002", title: "C"))
|
||
try fixture.item(
|
||
"\(Ident.lane1)/\(Ident.card4)",
|
||
"---\nschema: 1\norder: 0.5\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||
)
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||
try fixture.item("\(Ident.lane2)/\(MoreIdent.card5)", Item.rich(order: "1024", title: "Other Lane's Card"))
|
||
|
||
let untouched = ["", Ident.lane1, Ident.lane2, "\(Ident.lane2)/\(MoreIdent.card5)", "\(Ident.lane1)/\(Ident.card4)"]
|
||
let renumbered = ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane1)/\(Ident.card2)", "\(Ident.lane1)/\(Ident.card3)"]
|
||
var before: [String: (data: Data, modified: Date)] = [:]
|
||
for path in untouched + renumbered { before[path] = try snapshot(fixture, path) }
|
||
|
||
try BoardWriter.renumberVisibleChildren(of: fixture.url(Ident.lane1))
|
||
|
||
for path in untouched {
|
||
let previous = try #require(before[path])
|
||
let after = try snapshot(fixture, path)
|
||
#expect(after.data == previous.data)
|
||
#expect(after.modified == previous.modified)
|
||
}
|
||
for path in renumbered {
|
||
let previous = try #require(before[path])
|
||
let after = try snapshot(fixture, path)
|
||
#expect(after.data != previous.data)
|
||
#expect(after.modified != previous.modified)
|
||
}
|
||
|
||
let orders = try [Ident.card2, Ident.card3, Ident.card1].map {
|
||
try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order
|
||
}
|
||
#expect(orders == [.valid(1024), .valid(2048), .valid(3072)])
|
||
}
|
||
}
|
||
|
||
// MARK: - Unknown-key order through a writer op
|
||
|
||
/// 01-storage-format.md § Fractal layout ▸ Rules, "unknown frontmatter keys and their order are
|
||
/// preserved verbatim on every rewrite" — load-bearing for agent overlays (08-agent-integration.md).
|
||
/// `BoardWriterPreservationTests.aTitleEditTouchesOnlyTheTitleAndTheStamps` already pins this for
|
||
/// three unknown keys grouped contiguously; this test strengthens it with five, deliberately
|
||
/// interleaved among the schema-owned keys rather than clustered, so the guarantee is about
|
||
/// *document order*, not merely "the unknown-keys subsequence".
|
||
struct WriteFidelityUnknownKeyOrderTests {
|
||
@Test func interleavedUnknownKeysKeepTheirSequenceThroughATitleRewrite() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
let original = """
|
||
---
|
||
schema: 1
|
||
alpha: 1
|
||
title: Before
|
||
beta: 2
|
||
order: 1024
|
||
gamma: 3
|
||
created: 2026-01-01T00:00:00Z
|
||
delta: 4
|
||
modified: 2026-01-01T00:00:00Z
|
||
epsilon: 5
|
||
---
|
||
Body.
|
||
|
||
"""
|
||
let folder = try fixture.item("card", original)
|
||
|
||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||
document.set(FrontmatterKeys.title, to: .string("After"))
|
||
}
|
||
|
||
let after = try fixture.indexText("card")
|
||
let excluded = [FrontmatterKeys.title, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
|
||
#expect(lines(of: after, excludingKeys: excluded) == lines(of: original, excludingKeys: excluded))
|
||
|
||
let document = try FrontmatterDocument.parse(after)
|
||
#expect(document.unknownFields.map(\.key) == ["alpha", "beta", "gamma", "delta", "epsilon"])
|
||
#expect(document.keys == [
|
||
"schema", "alpha", "title", "beta", "order", "gamma", "created", "delta", "modified", "epsilon",
|
||
])
|
||
#expect(document.title == .valid("After"))
|
||
}
|
||
}
|
||
|
||
// MARK: - Cross-cutting composite
|
||
|
||
/// The "a brand-new board is indistinguishable from a hand-edited one" claim, composed into one
|
||
/// session: a board built purely through `BoardWriter`, one file hand-edited outside it entirely
|
||
/// (simulating an agent or a human in a text editor), then moved, copied, tombstoned, restored,
|
||
/// and renumbered — and at the end `BoardLoader.load` succeeds with zero warnings, the hand-added
|
||
/// unknown keys survived, and every title and body reads back exactly as set.
|
||
struct WriteFidelityCompositeTests {
|
||
@Test func aBoardBuiltThroughTheWriterAndThenHandEditedLoadsCleanlyAfterEveryOperation() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
let root = fixture.url("Composite.kanban")
|
||
|
||
try BoardWriter.createBoard(at: root, title: "Composite")
|
||
let lane1 = try BoardWriter.createLane(inBoard: root, title: "Todo")
|
||
let lane2 = try BoardWriter.createLane(inBoard: root, title: "Doing")
|
||
let lane1Folder = root.appendingPathComponent(lane1.rawValue, isDirectory: true)
|
||
let lane2Folder = root.appendingPathComponent(lane2.rawValue, isDirectory: true)
|
||
|
||
let card1 = try BoardWriter.createCard(inLane: lane1Folder, title: "First")
|
||
let card2 = try BoardWriter.createCard(inLane: lane1Folder, title: "Second")
|
||
let card3 = try BoardWriter.createCard(inLane: lane1Folder, title: "Third")
|
||
for (id, body) in [(card1, "First body.\n"), (card2, "Second body.\n"), (card3, "Third body.\n")] {
|
||
try BoardWriter.updateIndex(
|
||
inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: .style(title: nil)
|
||
) { $0.body = body }
|
||
}
|
||
|
||
// A hand edit: an agent opens Second's file directly and adds unknown keys plus a
|
||
// comment — no `BoardWriter` call involved, exactly what "indistinguishable" has to
|
||
// survive everything that follows.
|
||
let card2Path = "Composite.kanban/\(lane1.rawValue)/\(card2.rawValue)"
|
||
let handEdited = try fixture.indexText(card2Path).replacingOccurrences(
|
||
of: "modified:",
|
||
with: "project: lanework\nsphere: work # agent overlay\n# a note\nmodified:"
|
||
)
|
||
try fixture.item(card2Path, handEdited)
|
||
|
||
// Move: First crosses into Doing — same board, so identity travels unchanged.
|
||
let moveResult = try BoardWriter.moveItem(
|
||
at: lane1Folder.appendingPathComponent(card1.rawValue),
|
||
toParent: lane2Folder,
|
||
sourceBoardRoot: root,
|
||
destinationBoardRoot: root,
|
||
order: nil
|
||
)
|
||
#expect(moveResult.id == card1)
|
||
#expect(moveResult.reminted.isEmpty)
|
||
|
||
// Copy: Third forks into Doing alongside it — a new identity, the original left in Todo.
|
||
let copyID = try BoardWriter.copyItem(
|
||
at: lane1Folder.appendingPathComponent(card3.rawValue), toParent: lane2Folder, order: nil, stamps: .fork
|
||
)
|
||
#expect(copyID != card3)
|
||
|
||
// Delete, then restore, the hand-edited card — two folder moves now (03-board-ui.md § Trash,
|
||
// resettled 2026-07-28), so its unknown keys, its comment and its body must ride along
|
||
// untouched through both legs.
|
||
try BoardWriter.deleteCardToTrash(
|
||
at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root
|
||
)
|
||
_ = try BoardWriter.moveItem(
|
||
at: BoardWriter.trashFolder(inBoard: root).appendingPathComponent(card2.rawValue),
|
||
toParent: lane1Folder,
|
||
sourceBoardRoot: root,
|
||
destinationBoardRoot: root,
|
||
order: nil
|
||
)
|
||
|
||
// Renumber both lanes — the fallback that touches every visible sibling's `order`.
|
||
try BoardWriter.renumberVisibleChildren(of: lane1Folder)
|
||
try BoardWriter.renumberVisibleChildren(of: lane2Folder)
|
||
|
||
let result = try BoardLoader.load(boardRoot: root)
|
||
#expect(result.warnings.isEmpty)
|
||
#expect(result.model.title == .valid("Composite"))
|
||
|
||
let lanes = Dictionary(uniqueKeysWithValues: result.model.lanes.map { ($0.id, $0) })
|
||
let todo = try #require(lanes[lane1])
|
||
let doing = try #require(lanes[lane2])
|
||
|
||
// Todo kept the two cards that never left: Second (hand-edited, survived a full
|
||
// delete/restore round trip) and Third (untouched original, still resident after being
|
||
// only *copied*, never moved).
|
||
#expect(Set(todo.cards.map(\.id)) == [card2, card3])
|
||
let restoredSecond = try #require(todo.cards.first { $0.id == card2 })
|
||
#expect(restoredSecond.title == .valid("Second"))
|
||
#expect(restoredSecond.body == "Second body.\n")
|
||
#expect(restoredSecond.isDeleted == false)
|
||
#expect(restoredSecond.document.unknownFields.map(\.key) == ["project", "sphere"])
|
||
#expect(restoredSecond.document.rawValue(for: "project") == "lanework")
|
||
let untouchedThird = try #require(todo.cards.first { $0.id == card3 })
|
||
#expect(untouchedThird.title == .valid("Third"))
|
||
#expect(untouchedThird.body == "Third body.\n")
|
||
|
||
// Doing holds the mover (original identity) and the fork (a fresh one).
|
||
#expect(Set(doing.cards.map(\.id)) == [card1, copyID])
|
||
let movedFirst = try #require(doing.cards.first { $0.id == card1 })
|
||
#expect(movedFirst.title == .valid("First"))
|
||
#expect(movedFirst.body == "First body.\n")
|
||
let forkedThird = try #require(doing.cards.first { $0.id == copyID })
|
||
#expect(forkedThird.title == .valid("Third"))
|
||
#expect(forkedThird.body == "Third body.\n")
|
||
}
|
||
}
|
||
|
||
// MARK: - The container-change predicate
|
||
|
||
/// **01-storage-format.md § Frontmatter ▸ `modified`'s scope** — ruled 2026-07-29 as
|
||
/// moves-don't-stamp, **refined 2026-07-30** to one container-change predicate:
|
||
///
|
||
/// > a reorder within the item's container (a card among its lane's siblings, a lane among the
|
||
/// > board's lanes) and a renumber's whole-lane rescale rewrite `index.md` without touching content:
|
||
/// > no stamp, and no `modified-by` clear … **A move that changes the item's container stamps both**:
|
||
/// > a cross-lane move, a cross-board arrival, and the trash move.
|
||
///
|
||
/// The pairing is the thing these tests are really pinning: `modified` and `modified-by` move
|
||
/// together, always, because "attribution can't change when content didn't". So every case below
|
||
/// asserts both keys, and the fixtures deliberately carry a foreign `modified-by: claude` — the key
|
||
/// whose survival is the only visible difference between an order-only rewrite and a content one.
|
||
///
|
||
/// **There is deliberately no trash case in the implementation**, and that is what
|
||
/// `theTrashMoveStampsBecauseEveryContainerChangeDoes` exists to state from the outside: the trash
|
||
/// move stamps, and it does so through the same predicate as a cross-lane move rather than through a
|
||
/// branch of its own.
|
||
struct WriteFidelityStampingTests {
|
||
|
||
/// The prior stamps every fixture below starts from — `Item.rich`'s own, so a test asserting
|
||
/// "unchanged" is asserting against a real value that a stamp would visibly replace.
|
||
private static let priorModified = "2026-02-02T09:00:00Z"
|
||
|
||
private func stamps(_ fixture: WriterFixture, _ path: String) throws -> (modified: String?, modifiedBy: String?) {
|
||
let document = try FrontmatterDocument.parse(fixture.indexText(path))
|
||
return (document.rawValue(for: FrontmatterKeys.modified), document.rawValue(for: FrontmatterKeys.modifiedBy))
|
||
}
|
||
|
||
private func twoLaneBoard() 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("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||
return fixture
|
||
}
|
||
|
||
/// A card dropped back into its own lane — `moveItem`'s same-parent degenerate path, which is
|
||
/// every within-lane drag, every ⌥⌘↑/↓ sort step, and every inverse of one.
|
||
@Test("A card reordered among its lane's siblings rewrites only order")
|
||
func aWithinLaneReorderRewritesOnlyOrder() throws {
|
||
let fixture = try twoLaneBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
_ = try BoardWriter.moveItem(
|
||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||
toParent: fixture.url(Ident.lane1),
|
||
sourceBoardRoot: fixture.root,
|
||
destinationBoardRoot: fixture.root,
|
||
order: 3072
|
||
)
|
||
|
||
let after = try stamps(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||
#expect(after.modified == Self.priorModified, "a reorder is not a content write")
|
||
#expect(after.modifiedBy == "claude", "and attribution can't change when content didn't")
|
||
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)"))
|
||
#expect(document.order.value == 3072, "the one key a reorder owns did move")
|
||
}
|
||
|
||
/// A lane's parent is the board root and nothing else, so *every* lane reorder is
|
||
/// within-container — ⌘←/⌘→, the strip drag, and their inverses alike.
|
||
@Test("A lane reordered on the board rewrites only order")
|
||
func aLaneReorderRewritesOnlyOrder() throws {
|
||
let fixture = try twoLaneBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
_ = try BoardWriter.moveItem(
|
||
at: fixture.url(Ident.lane2),
|
||
toParent: fixture.root,
|
||
sourceBoardRoot: fixture.root,
|
||
destinationBoardRoot: fixture.root,
|
||
order: 512
|
||
)
|
||
|
||
let after = try stamps(fixture, Ident.lane2)
|
||
#expect(after.modified == Self.priorModified)
|
||
#expect(after.modifiedBy == "claude")
|
||
#expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane2)).order.value == 512)
|
||
}
|
||
|
||
/// The renumber rescale — 01 § Ordering, verbatim: "order-only rewrites, so no `modified` stamp
|
||
/// and no `modified-by` clear". Every sibling in the lane is rewritten, and not one of them is
|
||
/// stamped, which is what keeps a midpoint exhaustion from reading as a lane's worth of edits.
|
||
@Test("A renumber rescale stamps nothing, on any sibling")
|
||
func aRenumberRescaleStampsNothing() throws {
|
||
let fixture = try twoLaneBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
try BoardWriter.renumberVisibleChildren(of: fixture.url(Ident.lane1))
|
||
|
||
for path in ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane1)/\(Ident.card2)"] {
|
||
let after = try stamps(fixture, path)
|
||
#expect(after.modified == Self.priorModified, "\(path) was stamped by a rescale")
|
||
#expect(after.modifiedBy == "claude", "\(path) lost its attribution to a rescale")
|
||
}
|
||
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")).order.value == 1024)
|
||
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card2)")).order.value == 2048)
|
||
}
|
||
|
||
/// The other side of the predicate: which lane a card lives in is *state*, so crossing lanes is a
|
||
/// content write and stamps both keys.
|
||
@Test("A cross-lane move stamps modified and clears modified-by")
|
||
func aCrossLaneMoveStamps() throws {
|
||
let fixture = try twoLaneBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
_ = try BoardWriter.moveItem(
|
||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||
toParent: fixture.url(Ident.lane2),
|
||
sourceBoardRoot: fixture.root,
|
||
destinationBoardRoot: fixture.root,
|
||
order: 1024
|
||
)
|
||
|
||
let after = try stamps(fixture, "\(Ident.lane2)/\(Ident.card1)")
|
||
#expect(after.modified != Self.priorModified, "a container change is a content write")
|
||
#expect(after.modifiedBy == nil, "and clears the foreign stamp like any app write")
|
||
}
|
||
|
||
/// **No trash special case anywhere.** The delete stamps, the restore stamps, and both do it
|
||
/// through the container predicate rather than through a rule of their own — which is why this
|
||
/// test asserts the same two facts as `aCrossLaneMoveStamps` and nothing extra.
|
||
@Test("The trash move stamps because every container change does — in and out")
|
||
func theTrashMoveStampsBecauseEveryContainerChangeDoes() throws {
|
||
let fixture = try twoLaneBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
try BoardWriter.deleteCardToTrash(
|
||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), inBoard: fixture.root
|
||
)
|
||
let trashed = try stamps(fixture, ".trash/\(Ident.card1)")
|
||
#expect(trashed.modified != Self.priorModified, "into the trash is a container change")
|
||
#expect(trashed.modifiedBy == nil)
|
||
|
||
// And out again. `modified-by` is re-planted by hand first, standing in for the agent that
|
||
// re-stamped the card while it sat in the trash: the restore has to clear it again.
|
||
try BoardWriter.updateIndex(
|
||
inItemFolder: fixture.url(".trash/\(Ident.card1)"), operation: .style(title: nil)
|
||
) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) }
|
||
_ = try BoardWriter.moveItem(
|
||
at: fixture.url(".trash/\(Ident.card1)"),
|
||
toParent: fixture.url(Ident.lane2),
|
||
sourceBoardRoot: fixture.root,
|
||
destinationBoardRoot: fixture.root,
|
||
order: 4096
|
||
)
|
||
let restored = try stamps(fixture, "\(Ident.lane2)/\(Ident.card1)")
|
||
#expect(restored.modifiedBy == nil, "out of the trash is a container change too")
|
||
}
|
||
|
||
/// A lane's trash move is the same container change, and its **subtree is not** — the nested
|
||
/// card is neither read nor rewritten, so its stamps are exactly what they were.
|
||
@Test("A lane's trash move stamps the lane and nothing beneath it")
|
||
func aLaneTrashMoveStampsOnlyTheLane() throws {
|
||
let fixture = try twoLaneBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
try BoardWriter.deleteLaneToTrash(
|
||
at: fixture.url(Ident.lane1), inBoard: fixture.root
|
||
)
|
||
|
||
let lane = try stamps(fixture, ".trash/\(Ident.lane1)")
|
||
#expect(lane.modified != Self.priorModified, "into the trash is a container change")
|
||
#expect(lane.modifiedBy == nil)
|
||
|
||
let card = try stamps(fixture, ".trash/\(Ident.lane1)/\(Ident.card1)")
|
||
#expect(card.modified == Self.priorModified, "the freight's own container did not change")
|
||
#expect(card.modifiedBy == "claude", "so even a foreign stamp survives, untouched")
|
||
}
|
||
|
||
/// A cross-board arrival changes the container as surely as a cross-lane move does, and the
|
||
/// import boundary's remint does not change that: the arrived file is stamped either way.
|
||
@Test("A cross-board arrival stamps")
|
||
func aCrossBoardArrivalStamps() throws {
|
||
let source = try twoLaneBoard()
|
||
defer { source.tearDown() }
|
||
let destination = try WriterFixture()
|
||
defer { destination.tearDown() }
|
||
try destination.item("", Item.board)
|
||
try destination.item(Ident.lane3, Item.rich(order: "1024", title: "Elsewhere"))
|
||
|
||
let result = try BoardWriter.moveItem(
|
||
at: source.url("\(Ident.lane1)/\(Ident.card1)"),
|
||
toParent: destination.url(Ident.lane3),
|
||
sourceBoardRoot: source.root,
|
||
destinationBoardRoot: destination.root,
|
||
order: 1024
|
||
)
|
||
|
||
let after = try stamps(destination, "\(Ident.lane3)/\(result.id.rawValue)")
|
||
#expect(after.modified != Self.priorModified)
|
||
#expect(after.modifiedBy == nil)
|
||
}
|
||
|
||
/// The predicate as a pure value — one exhaustive statement of which operations are order-only,
|
||
/// so a new `WriteOperation` cannot quietly join or leave the class. **`.reorder` and
|
||
/// `.renumberChildren`, and nothing else**; `.delete` and `.move` are named explicitly because
|
||
/// they are the two a "moves don't stamp" reading would have put on the wrong side.
|
||
@Test("Only reorder and renumber are order-only")
|
||
func theOrderOnlyClassIsExactlyTwoOperations() {
|
||
#expect(WriteOperation.reorder(title: nil).rewritesOrderOnly)
|
||
#expect(WriteOperation.renumberChildren.rewritesOrderOnly)
|
||
|
||
for operation: WriteOperation in [
|
||
.createBoard, .createLane, .createCard, .move(title: nil), .copy(title: nil),
|
||
.paste(title: nil), .delete(title: nil), .purge(title: nil), .migrateTombstone(title: nil),
|
||
.style(title: nil), .resize(title: nil), .rename(title: nil), .duplicateBoard(title: nil),
|
||
.saveAsTemplate(title: nil), .importAttachment(filename: "a"), .listAttachments,
|
||
.removeAttachment(filename: "a"), .relocateLooseFile(filename: "a"), .agentGuide,
|
||
.displaceClaimedName(name: ".trash"), .repairDuplicateID(title: nil),
|
||
.toggleTask(title: nil), .editBody(title: nil), .rawSource(title: nil),
|
||
] {
|
||
#expect(operation.rewritesOrderOnly == false, "\(operation) should be a content write")
|
||
}
|
||
}
|
||
}
|