Realign code with the 2026-07-29 findings-resolution rulings
Nine rulings land as code. Reorders don't stamp — one container-change predicate (WriteOperation.rewritesOrderOnly): within-container reorders and the renumber rescale rewrite only order, while cross-lane, cross-board, and trash moves stamp modified and clear modified-by; no trash special case exists, and the m8 undo inverses conform through the same seam. Copies are transactions: the root-strict/nested-lenient split retires for a whole-subtree stampability preflight that refuses loudly naming the offender, and every item-level copy severs remote/remote-state at every level (whole-board forks carry them verbatim). Paste refuses, never degrades: the embedded-index.md materialization and its loss row retire; a missing staged snapshot produces nothing and posts an error-tone one-shot named from manifest metadata. Coerce-tier fallbacks log through the Defect stream with path context attached loader-side. Displacement is level-uniform: a file squatting attachments inside a card heals by the same rename ladder as board-root squatters; comments stays tolerated. Delete Immediately joins card and lane context menus as Delete's ⌥-alternate with its own VO custom action, routed through an explicit container so the menu target outranks standing selection. Agent guide v7 teaches the stamp discipline and the card-level attachments claim, and sheds two stale v6 lines (lanes trash now; kind is taught). Verified conformant, unchanged: edition-aware Undo/Redo disable, trash marquee full-height backdrop. Both schemes 1854 tests / 318 suites green; verify-editions 30/30. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -647,6 +647,25 @@ struct AgentGuideContentTests {
|
||||
#expect(content.contains("schema: 1"))
|
||||
}
|
||||
|
||||
/// v7's list, 08 ▸ The agent guide's "v7 additionally teaches" bullet: the refined
|
||||
/// stamp-discipline predicate (01-storage-format.md ▸ `modified`'s scope, ruled 2026-07-29,
|
||||
/// refined 2026-07-30) and the card-level `attachments` claimed name (01-storage-format.md
|
||||
/// § Fractal layout ▸ Rules, "level-uniform"). Each pin is a phrase an agent reading the guide
|
||||
/// would actually see, not a paraphrase — so a wording rewrite that silently drops the rule
|
||||
/// fails here instead of shipping quietly.
|
||||
@Test("v7 teaches the stamp-discipline predicate and the attachments claimed name")
|
||||
func v7VocabularyIsPresent() {
|
||||
let content = AgentGuide.content
|
||||
// The predicate itself: one rule, not a trash special case.
|
||||
#expect(content.contains("a move that changes an item's container"))
|
||||
#expect(content.contains("rewrites only `order`"))
|
||||
#expect(content.contains("into/out of `.trash/`"))
|
||||
#expect(content.contains("The trash move isn't an exception"))
|
||||
// The attachments claimed name: don't squat the folder's name with a file.
|
||||
#expect(content.contains("The name `attachments` itself belongs to that folder"))
|
||||
#expect(content.contains("*file* called `attachments` in a card"))
|
||||
}
|
||||
|
||||
/// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
|
||||
/// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate
|
||||
/// mention of `deleted:` is the warning never to write it.
|
||||
@@ -656,5 +675,10 @@ struct AgentGuideContentTests {
|
||||
#expect(!content.contains("media/"))
|
||||
#expect(!content.contains("tombstone"))
|
||||
#expect(content.contains("Never write a `deleted:` key"))
|
||||
// The 2026-07-29 rule was first named "moves-don't-stamp"; the 2026-07-30 refinement
|
||||
// retired that framing (container changes stamp, the trash move included) — the guide
|
||||
// must never teach the superseded shape of the rule.
|
||||
#expect(!content.contains("moves don't stamp"))
|
||||
#expect(!content.contains("moves-don't-stamp"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,3 +828,130 @@ struct BoardLoaderEncodingTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The coerce tier's trace (01-storage-format.md § Frontmatter, ruled 2026-07-29)
|
||||
|
||||
/// **"A no-sensible-reading fallback logs"** — field, path, and raw text, carried as coerce-tier
|
||||
/// entries in the integrity service's Defect stream:
|
||||
///
|
||||
/// > the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or a
|
||||
/// > notice; no banner, no behavior change.
|
||||
///
|
||||
/// So these tests assert two things at once, and the second matters as much as the first: the fallback
|
||||
/// is *reported*, and nothing about the board changed because of it — the field still renders its
|
||||
/// default, the bytes on disk are still verbatim, and no heal is scheduled.
|
||||
@Suite("BoardLoader ▸ coerce-tier fallbacks")
|
||||
struct BoardLoaderCoercionTraceTests {
|
||||
|
||||
/// The pure half first: which lenient fields report, and which deliberately do not.
|
||||
///
|
||||
/// `schema` and `order` are the **refuse** tier — a malformed one fails the load loudly, so there is
|
||||
/// no silent recovery to leave a trace of — and `deleted`'s rule is presence-not-validity, so
|
||||
/// nothing falls back to a default there either.
|
||||
@Test("The document reports its lenient fallbacks, and only those")
|
||||
func theDocumentReportsItsLenientFallbacks() throws {
|
||||
let document = try FrontmatterDocument.parse("""
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: [a, b]
|
||||
width: 1.5
|
||||
created: not-a-date
|
||||
icon: {a: b}
|
||||
deleted: also-not-a-date
|
||||
---
|
||||
Body.
|
||||
""")
|
||||
|
||||
let byKey = Dictionary(uniqueKeysWithValues: document.coercedFields.map { ($0.key, $0.raw) })
|
||||
#expect(Set(byKey.keys) == ["title", "width", "created", "icon"])
|
||||
#expect(byKey["width"] == "1.5", "the raw text as written — what a future heuristic would read")
|
||||
#expect(byKey["created"] == "not-a-date")
|
||||
#expect(byKey["deleted"] == nil, "presence, not validity, decides a tombstone")
|
||||
}
|
||||
|
||||
@Test("A clean document reports nothing")
|
||||
func aCleanDocumentReportsNothing() throws {
|
||||
let document = try FrontmatterDocument.parse("""
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Fine
|
||||
width: 2
|
||||
---
|
||||
""")
|
||||
#expect(document.coercedFields.isEmpty)
|
||||
}
|
||||
|
||||
/// **A scalar of the wrong type is not a fallback** — it coerces to the text the author typed
|
||||
/// (`title: 2048` reads as "2048"), which is a *successful* reading and leaves no trace. Only "no
|
||||
/// sensible reading exists" does.
|
||||
@Test("A coerced scalar leaves no trace — it was read, not defaulted")
|
||||
func aCoercedScalarLeavesNoTrace() throws {
|
||||
let document = try FrontmatterDocument.parse("---\nschema: 1\ntitle: 2048\nwidth: \"3\"\n---\n")
|
||||
#expect(document.title.value == "2048")
|
||||
#expect(document.width.value == 3)
|
||||
#expect(document.coercedFields.isEmpty)
|
||||
}
|
||||
|
||||
/// The loader's half: the path is attached at every level, because the rule is about fields and
|
||||
/// every level has them.
|
||||
@Test("The loader attaches the path, at every level")
|
||||
func theLoaderAttachesThePath() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let lane = uuidFolderName()
|
||||
let card = uuidFolderName()
|
||||
let trashed = uuidFolderName()
|
||||
try fixture.index("", "schema: 1\ntitle: [a, b]\n")
|
||||
try fixture.index(lane, "schema: 1\norder: 1024\nwidth: 1.5\n")
|
||||
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nicon: {x: y}\n")
|
||||
try fixture.index(".trash/\(trashed)", "schema: 1\norder: 1024\ncreated: nope\n")
|
||||
|
||||
let reported = try BoardLoader.load(boardRoot: fixture.root).coercedFrontmatter
|
||||
let byPath = Dictionary(uniqueKeysWithValues: reported.map { ($0.path, $0.fields.map(\.key)) })
|
||||
|
||||
#expect(byPath["index.md"] == ["title"])
|
||||
#expect(byPath["\(lane)/index.md"] == ["width"])
|
||||
#expect(byPath["\(lane)/\(card)/index.md"] == ["icon"])
|
||||
#expect(byPath[".trash/\(trashed)/index.md"] == ["created"])
|
||||
}
|
||||
|
||||
/// **No behavior change** — the whole point of the tier. The fields render their defaults exactly as
|
||||
/// they did before anything was reported, and the bytes are preserved verbatim.
|
||||
@Test("Nothing about the board changes — defaults render, bytes stay")
|
||||
func nothingChanges() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let lane = uuidFolderName()
|
||||
try fixture.index("", "schema: 1\ntitle: Board\n")
|
||||
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: [a, b]\nwidth: 0.5\n")
|
||||
let before = try Data(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md"))
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
let loaded = try #require(result.model.lanes.first)
|
||||
|
||||
#expect(loaded.title.isMalformed, "the field still reads as malformed")
|
||||
#expect(loaded.title.value == nil, "and renders its default — the untitled placeholder")
|
||||
#expect(loaded.width.value == nil, "width falls back to 1 at the render layer, not here")
|
||||
#expect(try Data(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md")) == before,
|
||||
"read-side only: the loader never writes")
|
||||
#expect(result.warnings.isEmpty, "a coercion is not a stray warning")
|
||||
}
|
||||
|
||||
/// **It is not healable work**, which is why it has no class: a class is a memo key and a
|
||||
/// banner-posture row in the engine, and inventing one would arm a memo against a repair nobody
|
||||
/// wrote. The other defects keep theirs.
|
||||
@Test("A coerce-tier defect has no heal class, and signs per field")
|
||||
func itHasNoHealClass() {
|
||||
let defect = IntegrityRules.Defect.coercedFrontmatter(CoercedFrontmatter(
|
||||
path: "lane/card/index.md",
|
||||
fields: [CoercedField(key: "width", raw: "1.5"), CoercedField(key: "icon", raw: "{}")]
|
||||
))
|
||||
#expect(defect.healClass == nil)
|
||||
#expect(Set(defect.signatures) == [
|
||||
"coerce:lane/card/index.md:width",
|
||||
"coerce:lane/card/index.md:icon",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,18 +423,28 @@ struct BoardWriterRenumberTests {
|
||||
#expect(try fixture.indexText("lane/\(Child.b)").contains("order: 1024\n"))
|
||||
}
|
||||
|
||||
@Test func eachRewrittenChildIsStampedAndKeepsItsUnknownKeys() throws {
|
||||
/// **A rescale stamps nothing** (01-storage-format.md § Ordering, verbatim: "order-only rewrites,
|
||||
/// so no `modified` stamp and no `modified-by` clear"; § Frontmatter ▸ `modified`'s scope, refined
|
||||
/// 2026-07-30). Every sibling's file is rewritten and not one of them is stamped — a foreign
|
||||
/// `modified-by` survives, which is the pairing read at its sharpest: attribution cannot change when
|
||||
/// content didn't.
|
||||
///
|
||||
/// The prior version of this test asserted the opposite (`modifiedBy == .missing`, `modified != nil`)
|
||||
/// under the pre-2026-07-29 rule that every app write stamps.
|
||||
@Test func eachRewrittenChildKeepsItsStampsAndItsUnknownKeys() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let lane = try crowdedLane(fixture)
|
||||
let priorModified = try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.a)"))
|
||||
.rawValue(for: FrontmatterKeys.modified)
|
||||
|
||||
try BoardWriter.renumberVisibleChildren(of: lane)
|
||||
|
||||
for name in [Child.a, Child.b, Child.c] {
|
||||
let text = try fixture.indexText("lane/\(name)")
|
||||
let document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.modifiedBy == .missing)
|
||||
#expect(document.modified.value != nil)
|
||||
#expect(document.modifiedBy == .valid("claude"), "a rescale touches no content, so attribution stands")
|
||||
#expect(document.rawValue(for: FrontmatterKeys.modified) == priorModified, "and nothing is stamped")
|
||||
#expect(document.unknownFields.map(\.key) == ["project"])
|
||||
#expect(text.contains("project: lanework # agent overlay\n"))
|
||||
#expect(document.body.hasSuffix(" body\n"))
|
||||
@@ -1458,14 +1468,81 @@ struct BoardWriterCopyTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The leniency below the root: a nested file the surgical editor cannot key is copied
|
||||
/// verbatim rather than failing the gesture — stale `modified-by` and all — while its
|
||||
/// editable siblings are stamped normally.
|
||||
@Test func aNestedUneditableFileCopiesVerbatimWhileItsSiblingsAreStamped() throws {
|
||||
/// **A copy is a transaction** (01-storage-format.md § Frontmatter, ruled 2026-07-29): a nested
|
||||
/// card the surgical editor cannot key refuses the **whole** copy, naming that card, and nothing is
|
||||
/// materialized at the destination.
|
||||
///
|
||||
/// This replaced the former nested leniency, which copied such a card verbatim — stale `modified-by`
|
||||
/// and all — and stamped its siblings normally. The kindness was the one verdict 01's doctrine
|
||||
/// forbids: "proceed partially, lose a little" is never a verdict, and a silently unstamped
|
||||
/// descendant now also carries a live tracker claim it has no right to (the `remote` sever).
|
||||
@Test func aNestedUneditableFileRefusesTheWholeCopyNamingIt() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
// `Item.uneditable`'s own title, so the refusal has a name to carry.
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
|
||||
let before = try fixture.entryNames("A.kanban")
|
||||
|
||||
let error = writeFailure {
|
||||
_ = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
toParent: fixture.url("A.kanban"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
}
|
||||
|
||||
let failure = try #require(error)
|
||||
#expect(failure.operation == .copy(title: "Odd"), "the refusal names the offending item")
|
||||
if case .uneditableFrontmatter = failure.reason {} else {
|
||||
Issue.record("expected the uneditable-shape refusal, got \(failure.reason)")
|
||||
}
|
||||
#expect(failure.path.contains(Ident.card3), "and the offending file's own path")
|
||||
#expect(try fixture.entryNames("A.kanban") == before, "nothing was materialized")
|
||||
}
|
||||
|
||||
/// The preflight runs over the **source**, so a refusal is free: the copy is refused before a single
|
||||
/// byte is written, rather than materialized and then cleaned up.
|
||||
@Test func aRefusedCopyNeverTouchesTheDestinationAtAll() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
|
||||
// A second board, so "nothing at the destination" is a claim about an empty container rather
|
||||
// than about a folder that happens to hold the source too.
|
||||
try fixture.item("B.kanban", Item.board)
|
||||
try fixture.item("B.kanban/\(Ident.lane3)", Item.rich(order: "1024", title: "Elsewhere"))
|
||||
|
||||
_ = writeFailure {
|
||||
_ = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
toParent: fixture.url("B.kanban"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
}
|
||||
|
||||
#expect(try fixture.entryNames("B.kanban").sorted() == ["index.md", Ident.lane3].sorted())
|
||||
}
|
||||
|
||||
/// **Item-level copies sever tracker identity** (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// ruled 2026-07-29): "every folder an item-level copy materializes drops the reserved
|
||||
/// `remote`/`remote-state` keys, at every level … because two local objects must never both claim to
|
||||
/// be the same remote object". The **source** keeps both, because a sever is something a copy does to
|
||||
/// itself.
|
||||
///
|
||||
/// A lane copy, so both keys are exercised where the schema puts them — `remote-state` on the lane,
|
||||
/// `remote` on its cards — and both levels are asserted, which is what "at every level" means.
|
||||
@Test func anItemLevelCopySeversTheReservedTrackerKeys() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("A.kanban", Item.board)
|
||||
try fixture.item("A.kanban/\(Ident.lane1)", Item.tracked(order: "1024", title: "Todo", key: "remote-state"))
|
||||
try fixture.item(
|
||||
"A.kanban/\(Ident.lane1)/\(Ident.card1)",
|
||||
Item.tracked(order: "1024", title: "Card One", key: "remote")
|
||||
)
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
@@ -1474,10 +1551,51 @@ struct BoardWriterCopyTests {
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture)
|
||||
#expect(try fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Odd"]))") == Item.uneditable)
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card One"]))"))
|
||||
.modifiedBy == .missing)
|
||||
let lane = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)"))
|
||||
#expect(lane.value(for: "remote-state") == nil, "the copied lane's tracker mapping is severed")
|
||||
#expect(lane.value(for: "project") != nil, "and every other unknown key is untouched")
|
||||
|
||||
let copiedCard = try #require(try fixture.entryNames("A.kanban/\(id.rawValue)").first(where: BoardLoader.isUUIDShaped))
|
||||
let card = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(copiedCard)"))
|
||||
#expect(card.value(for: "remote") == nil, "the copied card's too — at every level")
|
||||
#expect(card.value(for: "project") != nil)
|
||||
|
||||
// The originals still claim their remote objects: only the copy is severed.
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)"))
|
||||
.value(for: "remote-state") != nil)
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
||||
.value(for: "remote") != nil)
|
||||
}
|
||||
|
||||
/// Every occurrence goes, not just the winning one — `FrontmatterDocument.remove`'s own rule, and it
|
||||
/// matters here more than anywhere: a hand-duplicated `remote:` left behind would resurrect the
|
||||
/// claim the moment the winner were removed.
|
||||
@Test func theSeverTakesEveryOccurrenceOfTheKey() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("A.kanban", Item.board)
|
||||
try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", """
|
||||
---
|
||||
schema: 1
|
||||
title: Twinned
|
||||
order: 1024
|
||||
remote: gitea#1
|
||||
remote: gitea#2
|
||||
---
|
||||
Body.
|
||||
|
||||
""")
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"),
|
||||
toParent: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
let text = try fixture.indexText("A.kanban/\(Ident.lane1)/\(id.rawValue)")
|
||||
#expect(!text.contains("remote:"), "both occurrences went")
|
||||
}
|
||||
|
||||
/// A UUID-shaped folder with no `index.md` — interrupted-create residue — is reminted and
|
||||
@@ -1529,9 +1647,12 @@ struct BoardWriterCopyTests {
|
||||
#expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == before)
|
||||
}
|
||||
|
||||
/// All-or-nothing at the destination: a failure part-way through leaves no half-copied tree,
|
||||
/// because a partial copy is pure residue — nothing was there before.
|
||||
@Test func aFailedCopyLeavesNothingAtTheDestination() throws {
|
||||
/// All-or-nothing at the destination — and since the copy became a **transaction** (ruled
|
||||
/// 2026-07-29) this case never even materializes: an unreadable nested `index.md` is caught by the
|
||||
/// preflight, over the *source*, before a byte is copied. The claim is the same one, met earlier and
|
||||
/// more cheaply: nothing is at the destination, and `.unreadable` names the file rather than the
|
||||
/// half-finished copy of it.
|
||||
@Test func anUnreadableDescendantRefusesTheCopyBeforeItStarts() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
@@ -1548,10 +1669,11 @@ struct BoardWriterCopyTests {
|
||||
stamps: .fork
|
||||
)
|
||||
}
|
||||
guard case .io = error?.reason else {
|
||||
Issue.record("expected .io, got \(String(describing: error?.reason))")
|
||||
guard case .unreadable = error?.reason else {
|
||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
#expect(error?.path.contains(Ident.card2) == true, "the offending file is named")
|
||||
#expect(try fixture.entryNames("A.kanban") == before)
|
||||
}
|
||||
|
||||
@@ -1668,7 +1790,11 @@ struct BoardWriterSameParentMoveTests {
|
||||
// and not after itself miscounted (which would give 2048... or 4096+1024).
|
||||
let text = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||
#expect(text.contains("order: 4096"))
|
||||
#expect(!text.contains("modified-by:"))
|
||||
// **The same-parent path is a reorder, so it stamps nothing** (01-storage-format.md
|
||||
// § Frontmatter ▸ `modified`'s scope, refined 2026-07-30): the container never changed, so the
|
||||
// foreign `modified-by` survives. This assertion was `!text.contains("modified-by:")` under the
|
||||
// pre-refinement rule that every app write clears it.
|
||||
#expect(text.contains("modified-by: claude"))
|
||||
}
|
||||
|
||||
@Test func aSameParentMoveWithAnExplicitOrderJustRewritesIt() throws {
|
||||
|
||||
@@ -287,3 +287,174 @@ struct ClaimedNamePhrasingTests {
|
||||
== "Couldn't move '.trash' aside — Lanework needs that name — permission denied")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card level
|
||||
|
||||
/// **The rule is level-uniform** (01-storage-format.md § Fractal layout ▸ Rules, extended
|
||||
/// 2026-07-29):
|
||||
///
|
||||
/// > a card's reserved child names are claimed the same way — a regular file or symlink squatting
|
||||
/// > `attachments` (a directory name) displaces by the same ladder (`attachments` → `attachments 2`),
|
||||
/// > so imports, Finder drops, and the sidebar listing never fail one gesture at a time against a
|
||||
/// > squatted name; the displaced file, now an ordinary loose file, rides the next relocation into the
|
||||
/// > real `attachments/` — the heals compose.
|
||||
///
|
||||
/// The **reserved-but-unconsumed `comments`** is the timing principle's own illustration and is
|
||||
/// deliberately *not* displaced: nothing reads that name until the tracker era, so a wrong-kind holder
|
||||
/// degrades nothing while it stands and keeps the tolerated-stray posture.
|
||||
@MainActor
|
||||
@Suite("Claimed names ▸ the card level")
|
||||
struct CardClaimedNameTests {
|
||||
|
||||
/// A card holding a *file* called `attachments`. The load reports it and moves nothing — detection
|
||||
/// is read-only at every level.
|
||||
@Test("A file on a card's attachments is reported as a defect, and the load moves nothing")
|
||||
func fileOnAttachmentsIsADefect() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments", Data("not a folder".utf8))
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
|
||||
#expect(result.claimedNameSquatters == [
|
||||
ClaimedNameSquatter(
|
||||
name: "attachments",
|
||||
found: .file,
|
||||
expected: .directory,
|
||||
location: .card(path: "\(Ident.lane1)/\(Ident.card1)")
|
||||
),
|
||||
])
|
||||
#expect(
|
||||
try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments") == Data("not a folder".utf8),
|
||||
"the loader never writes"
|
||||
)
|
||||
// A claimed name is not a stray, so it never earns the stray-tolerance vocabulary — and it is
|
||||
// not a loose file either, so the relocation has nothing to say about it yet.
|
||||
#expect(result.warnings.isEmpty)
|
||||
#expect(result.looseCardFiles.isEmpty)
|
||||
}
|
||||
|
||||
/// A **symlink** wearing the name is the same defect and is moved *as a link*, never followed
|
||||
/// (01 § Fractal layout ▸ Rules: "symlinks are never traversed").
|
||||
@Test("A symlink on a card's attachments is the same defect")
|
||||
func symlinkOnAttachmentsIsADefect() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: fixture.url("\(Ident.lane1)/\(Ident.card1)").appendingPathComponent("attachments").path,
|
||||
withDestinationPath: "../elsewhere"
|
||||
)
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.claimedNameSquatters.map(\.found) == [.symlink])
|
||||
}
|
||||
|
||||
/// **`comments` stays tolerated** — the timing principle, stated as the absence of a defect.
|
||||
@Test("A file on a card's comments is not displaced — the name is not load-bearing yet")
|
||||
func fileOnCommentsIsTolerated() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/comments", Data("someday".utf8))
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.claimedNameSquatters.isEmpty)
|
||||
#expect(result.looseCardFiles.isEmpty, "a reserved name is not a loose file either")
|
||||
}
|
||||
|
||||
/// A real `attachments/` folder is a resident, not a squatter — the check is about the node's
|
||||
/// *kind*, and this is the negative case that keeps it honest.
|
||||
@Test("A real attachments folder is no defect at all")
|
||||
func aRealAttachmentsFolderIsFine() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01]))
|
||||
|
||||
#expect(try BoardLoader.load(boardRoot: fixture.root).claimedNameSquatters.isEmpty)
|
||||
}
|
||||
|
||||
/// The heal itself, end to end: the ladder renames it inside the **card's** folder, the notice names
|
||||
/// old and new, and the file's bytes are exactly what they were.
|
||||
@Test("The heal displaces it by the ladder, inside the card's own folder")
|
||||
func theHealDisplacesItInsideTheCard() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
||||
try fixture.file("\(cardPath)/attachments", Data("squatter".utf8))
|
||||
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.displaceClaimedNames()
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(try fixture.data("\(cardPath)/attachments 2") == Data("squatter".utf8), "preserved verbatim")
|
||||
#expect(
|
||||
IntegrityRules.node(at: fixture.url(cardPath).appendingPathComponent("attachments")) == nil,
|
||||
"and the name is free for the app"
|
||||
)
|
||||
#expect(store.banners.losses.count == 1)
|
||||
let message = try #require(store.banners.losses.first?.message)
|
||||
#expect(message.contains("attachments"))
|
||||
#expect(message.contains("attachments 2"))
|
||||
}
|
||||
|
||||
/// **The heals compose** — 01's own word for it: once displaced, the file is an ordinary loose file
|
||||
/// beside the card's `index.md`, which is exactly what the loose-file relocation exists for. One
|
||||
/// reload later it is inside the real `attachments/`.
|
||||
@Test("Displaced, then relocated: the heals compose")
|
||||
func theHealsCompose() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
||||
try fixture.file("\(cardPath)/attachments", Data("squatter".utf8))
|
||||
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.displaceClaimedNames()
|
||||
await store.awaitQuiescence()
|
||||
|
||||
// The next load sees an ordinary loose file where the squatter was.
|
||||
let after = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(after.claimedNameSquatters.isEmpty)
|
||||
#expect(after.looseCardFiles.map(\.fileNames) == [["attachments 2"]])
|
||||
|
||||
let relocating = try BoardStore(rootURL: fixture.root)
|
||||
relocating.relocateLooseCardFiles()
|
||||
await relocating.awaitQuiescence()
|
||||
|
||||
#expect(
|
||||
try fixture.data("\(cardPath)/attachments/attachments 2") == Data("squatter".utf8),
|
||||
"and it landed in the real attachments/"
|
||||
)
|
||||
}
|
||||
|
||||
/// Two cards squatting the name are **two pieces of work** in one bracket — the signature carries
|
||||
/// the location, so one card's failed heal has no claim to have failed the other's.
|
||||
@Test("Two squatted cards are two defects, healed in one bracket")
|
||||
func twoCardsAreTwoDefects() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments", Data("one".utf8))
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments", Data("two".utf8))
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.claimedNameSquatters.count == 2)
|
||||
#expect(Set(result.defects.flatMap(\.signatures)).count == 2, "distinct work, by location")
|
||||
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.displaceClaimedNames()
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments 2") == Data("one".utf8))
|
||||
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments 2") == Data("two".utf8))
|
||||
}
|
||||
|
||||
/// The table is the only thing to edit when `comments` graduates — pinned so the split is a stated
|
||||
/// rule rather than an accident of the probe's implementation.
|
||||
@Test("The card-level table claims attachments and comments, and displaces only attachments")
|
||||
func theTableStatesTheSplit() {
|
||||
let names = IntegrityRules.claimedCardChildNames
|
||||
#expect(names.map(\.name) == ["attachments", "comments"])
|
||||
#expect(names.allSatisfy { $0.expected == .directory })
|
||||
#expect(names.first { $0.name == "attachments" }?.displacesSquatters == true)
|
||||
#expect(names.first { $0.name == "comments" }?.displacesSquatters == false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,8 +181,8 @@ struct ClipboardManifestTests {
|
||||
#expect(ClipboardManifest(data: data) == nil)
|
||||
}
|
||||
|
||||
@Test("A lane entry's lost-attachment count totals its cards'")
|
||||
func lostAttachments() {
|
||||
@Test("A lane entry's attachment count totals its cards'")
|
||||
func totalAttachments() {
|
||||
let lane = ClipboardManifest.Entry(
|
||||
id: Ident.lane1,
|
||||
folder: Ident.lane1,
|
||||
@@ -194,7 +194,7 @@ struct ClipboardManifestTests {
|
||||
.init(id: Ident.card2, title: "Second", index: "b", attachmentCount: 1),
|
||||
]
|
||||
)
|
||||
#expect(lane.lostAttachmentCount == 3)
|
||||
#expect(lane.totalAttachmentCount == 3)
|
||||
}
|
||||
|
||||
@Test("The plain-text rendering is the titles, untitled items rendered as the board renders them")
|
||||
@@ -285,7 +285,7 @@ struct ClipboardCopyTests {
|
||||
#expect(manifest.kind == .lane)
|
||||
#expect(manifest.entries.map(\.id) == [Ident.lane1])
|
||||
#expect(manifest.entries[0].cards.map(\.id) == [Ident.card1, Ident.card2])
|
||||
#expect(manifest.entries[0].lostAttachmentCount == 2)
|
||||
#expect(manifest.entries[0].totalAttachmentCount == 2)
|
||||
}
|
||||
|
||||
@Test("A trash selection copies out, container recorded")
|
||||
@@ -830,73 +830,86 @@ struct PasteTargetTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The degraded paste's phrasing
|
||||
// MARK: - The refused paste's phrasing
|
||||
|
||||
@Suite("BannerCenter ▸ degraded paste")
|
||||
struct DegradedPasteBannerTests {
|
||||
/// **04-interactions.md ▸ Clipboard, re-ruled 2026-07-29** — refuse, never degrade:
|
||||
///
|
||||
/// > A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades …
|
||||
/// > the paste produces **nothing**, and a one-shot failure banner names it from the manifest's
|
||||
/// > metadata ("Couldn't paste 'Fix login' — the copied content is gone").
|
||||
///
|
||||
/// The retired suite these replace pinned `degradedPasteMessage(for:)` and its loss row — "Pasted
|
||||
/// 'Fix login' without its 3 attachments". Both are gone with the degraded materialization: nothing
|
||||
/// arrives, so there is no partial arrival to account for.
|
||||
@Suite("BannerCenter ▸ refused paste")
|
||||
struct RefusedPasteBannerTests {
|
||||
|
||||
/// 04's own example sentence, composed the way every failure headline is: the action clause the
|
||||
/// banner owns, an em dash, the cause.
|
||||
@Test("04's own example sentence")
|
||||
@MainActor
|
||||
func theExampleSentence() {
|
||||
#expect(BannerCenter.degradedPasteMessage(
|
||||
for: [.init(title: "Fix login", attachments: 3)]
|
||||
) == "Pasted 'Fix login' without its 3 attachments")
|
||||
let center = BannerCenter()
|
||||
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
|
||||
|
||||
#expect(center.oneShots.count == 1)
|
||||
let headline = try? #require(center.oneShots.first).error
|
||||
#expect(headline.map(BannerCenter.headline(for:)) == "Couldn't paste 'Fix login' — the copied content is gone")
|
||||
}
|
||||
|
||||
@Test("One attachment is singular")
|
||||
func singular() {
|
||||
#expect(BannerCenter.degradedPasteMessage(
|
||||
for: [.init(title: "Fix login", attachments: 1)]
|
||||
) == "Pasted 'Fix login' without its attachment")
|
||||
}
|
||||
|
||||
@Test("An untitled item is 'the item', never the Untitled rendering")
|
||||
/// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled entry is
|
||||
/// "the item" — `actionPhrase`'s standing convention for a failure with no title to quote.
|
||||
@Test("An untitled entry is 'the item', never the Untitled rendering")
|
||||
@MainActor
|
||||
func untitled() {
|
||||
#expect(BannerCenter.degradedPasteMessage(
|
||||
for: [.init(title: nil, attachments: 2)]
|
||||
) == "Pasted the item without its 2 attachments")
|
||||
}
|
||||
|
||||
@Test("Several items total their attachments rather than listing titles")
|
||||
func several() {
|
||||
#expect(BannerCenter.degradedPasteMessage(
|
||||
for: [.init(title: "A", attachments: 2), .init(title: "B", attachments: 3)]
|
||||
) == "Pasted 2 items without their 5 attachments")
|
||||
}
|
||||
|
||||
@Test("Nothing lost says nothing")
|
||||
func nothingLost() {
|
||||
#expect(BannerCenter.degradedPasteMessage(for: []) == nil)
|
||||
#expect(BannerCenter.degradedPasteMessage(for: [.init(title: "A", attachments: 0)]) == nil)
|
||||
}
|
||||
|
||||
@Test("Posting an empty loss list adds no row")
|
||||
@MainActor
|
||||
func postingNothing() {
|
||||
let center = BannerCenter()
|
||||
center.postDegradedPaste([])
|
||||
#expect(center.losses.isEmpty)
|
||||
center.postRefusedPaste(title: nil, stagedAt: "/tmp/staging/abc")
|
||||
let error = try? #require(center.oneShots.first).error
|
||||
#expect(error.map(BannerCenter.headline(for:)) == "Couldn't paste the item — the copied content is gone")
|
||||
}
|
||||
|
||||
/// **The pivot, stated as a class change**: the degraded paste was a loss row because the items
|
||||
/// landed and only their attachments did not. A refusal is *a write that did not happen*, which is
|
||||
/// 02-architecture.md's own definition of a one-shot — so it ranks with the true failures, carries
|
||||
/// the error tone, and posts no loss row at all.
|
||||
@Test("A refused paste is an error-tone one-shot, not a loss row")
|
||||
@MainActor
|
||||
func refusalIsAOneShotNotALossRow() {
|
||||
let center = BannerCenter()
|
||||
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
|
||||
|
||||
#expect(center.losses.isEmpty, "the degraded paste's loss row is retired")
|
||||
#expect(center.signposts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A degraded paste lands in the loss class, not the signpost class")
|
||||
@MainActor
|
||||
func postingLandsAsALossRow() {
|
||||
// Settled 2026-07-28 (DESIGN/02-architecture.md § The banner surface, "Loss rows"): the
|
||||
// degraded paste retoned from a signpost onto the new warning-tone loss class — content
|
||||
// that didn't arrive though nothing failed, ranking below the true failures and above the
|
||||
// ambient notices rather than at the bottom of the strip.
|
||||
let center = BannerCenter()
|
||||
center.postDegradedPaste([.init(title: "Fix login", attachments: 3)])
|
||||
|
||||
#expect(center.losses.count == 1)
|
||||
#expect(center.losses.first?.message == "Pasted 'Fix login' without its 3 attachments")
|
||||
#expect(center.signposts.isEmpty, "the degraded paste no longer posts a signpost")
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil, breakage: nil, oneShots: [], losses: center.losses, suspension: nil, operations: []
|
||||
lock: nil, breakage: nil, oneShots: center.oneShots, losses: [], suspension: nil, operations: []
|
||||
)
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .warning)
|
||||
#expect(rows[0].dismissID == center.losses.first?.id)
|
||||
#expect(rows[0].tone == .error)
|
||||
#expect(rows[0].dismissID == center.oneShots.first?.id)
|
||||
}
|
||||
|
||||
/// The staging path is what the error names, so a bug report about a refusal has something to go
|
||||
/// on — the file that was not there.
|
||||
@Test("The refusal names the staged path it could not find")
|
||||
@MainActor
|
||||
func namesTheStagedPath() {
|
||||
let center = BannerCenter()
|
||||
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
|
||||
#expect(center.oneShots.first?.error.path == "/tmp/staging/abc")
|
||||
#expect(center.oneShots.first?.error.reason == .clipboardContentGone)
|
||||
#expect(center.oneShots.first?.error.operation == .paste(title: "Fix login"))
|
||||
}
|
||||
|
||||
/// **The loss class survives the retirement** — 02's warning-tone class still has live producers
|
||||
/// (a Finder drop that skipped folders, the app's own relocation and repair notices); only the
|
||||
/// degraded-paste row left it.
|
||||
@Test("The loss class still has its other producers")
|
||||
@MainActor
|
||||
func theLossClassSurvives() {
|
||||
let center = BannerCenter()
|
||||
center.postSkippedFolders(count: 2)
|
||||
#expect(center.losses.count == 1)
|
||||
#expect(center.losses.first?.message == "Folders can't be attached — 2 skipped")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,10 +757,15 @@ struct LooseFilePasteTests {
|
||||
#expect(try BoardLoader.load(boardRoot: destination.root).looseCardFiles.isEmpty)
|
||||
}
|
||||
|
||||
/// The staging-less fallback carries only `index.md`, so there is nothing to normalize and the
|
||||
/// normalization must not invent an `attachments/` for a card that has none.
|
||||
@Test("A degraded paste normalizes nothing and mints no attachments folder")
|
||||
func degradedPasteIsUnaffected() async throws {
|
||||
/// **A refused paste normalizes nothing, because it materializes nothing** (04-interactions.md ▸
|
||||
/// Clipboard, re-ruled 2026-07-29 — refuse, never degrade).
|
||||
///
|
||||
/// This was the degraded fallback's normalization case: the fallback carried only `index.md`, so the
|
||||
/// claim was that normalization must not invent an `attachments/` for a card that had none. With the
|
||||
/// fallback retired the claim gets stronger and simpler — there is no arrival to normalize at all,
|
||||
/// and the destination is exactly what it was.
|
||||
@Test("A refused paste normalizes nothing because nothing arrives")
|
||||
func aRefusedPasteNormalizesNothing() async throws {
|
||||
let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard())
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makePasteDestination()
|
||||
@@ -776,10 +781,12 @@ struct LooseFilePasteTests {
|
||||
}
|
||||
|
||||
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
|
||||
let before = try destination.entryNames(Ident.lane4).sorted()
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let arrived = try arrivedCard(in: destination)
|
||||
#expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments"))
|
||||
#expect(try destination.entryNames("\(Ident.lane4)/\(arrived)") == ["index.md"])
|
||||
#expect(try destination.entryNames(Ident.lane4).sorted() == before, "no card arrived")
|
||||
#expect(target.banners.oneShots.count == 1, "and the refusal said so")
|
||||
// The resident is untouched — no attachments folder was invented anywhere in the lane.
|
||||
#expect(try destination.entryNames("\(Ident.lane4)/\(Ident.indexless)") == ["index.md"])
|
||||
}
|
||||
}
|
||||
|
||||
+113
-125
@@ -528,14 +528,34 @@ struct PasteCutTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The staging-less fallback
|
||||
// MARK: - Refuse, never degrade
|
||||
|
||||
/// **04-interactions.md ▸ Clipboard, re-ruled 2026-07-29** — Finder's invariant adopted:
|
||||
///
|
||||
/// > A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades …
|
||||
/// > the paste produces **nothing**, and a one-shot failure banner names it from the manifest's
|
||||
/// > metadata. An item arrives **whole — index, attachments, loose files, and comments when they ship
|
||||
/// > — or not at all** … The refusal is transactional — all-or-nothing for the whole paste.
|
||||
///
|
||||
/// These are the former `PasteFallbackTests`, turned around: every case that used to assert an item
|
||||
/// materialized from the manifest's embedded `index.md` now asserts that **nothing** was written and a
|
||||
/// failure banner names the entry. The manifest still embeds the text — it is what names the entry in
|
||||
/// the sentence below — it is simply never a materialization source.
|
||||
@MainActor
|
||||
@Suite("Paste ▸ the staging-less fallback")
|
||||
struct PasteFallbackTests {
|
||||
@Suite("Paste ▸ refuse, never degrade")
|
||||
struct PasteRefusalTests {
|
||||
|
||||
@Test("A missing snapshot falls back to the embedded index.md, byte-faithfully")
|
||||
func fallbackWritesTheSourceBytes() async throws {
|
||||
/// Drops the staged tree the way the world does: a sweep that ran early, an unreadable container,
|
||||
/// a full disk mid-copy.
|
||||
private func loseTheSnapshot(_ harness: ClipboardHarness) throws {
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A missing snapshot writes nothing at all")
|
||||
func aMissingSnapshotWritesNothing() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
@@ -545,31 +565,21 @@ struct PasteFallbackTests {
|
||||
harness.store.select([clipboardCard1], in: .board)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
// The snapshot goes — a swept tree, a full disk, an unreadable container.
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
try loseTheSnapshot(harness)
|
||||
|
||||
target.select([destinationLane], in: .board)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
||||
let document = try FrontmatterDocument.parse(destination.indexText("\(Ident.lane4)/\(arrived)"))
|
||||
#expect(document.title.value == "First")
|
||||
// Content intact: unknown keys, the comment's key, and the body all survived.
|
||||
#expect(document.value(for: "project") != nil)
|
||||
#expect(document.value(for: "labels") != nil)
|
||||
#expect(document.body.contains("First body — with *markdown*"))
|
||||
// `created` kept, fresh `order`.
|
||||
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
|
||||
#expect(document.order.value != 1024)
|
||||
// Attachments absent — which is exactly what the banner is about to say.
|
||||
#expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments"))
|
||||
#expect(
|
||||
try pastedTitles(destinationLane, in: destination) == ["Resident"],
|
||||
"the destination holds exactly what it held before"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A degraded paste banners, naming exactly what was lost")
|
||||
func fallbackBanners() async throws {
|
||||
/// 04's own example sentence, end to end: the entry is named from the manifest's metadata, which is
|
||||
/// the whole reason the embedded `index.md` is still carried.
|
||||
@Test("The refusal banners as a failure, naming the entry from the manifest")
|
||||
func theRefusalBanners() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
@@ -579,43 +589,43 @@ struct PasteFallbackTests {
|
||||
harness.store.select([clipboardCard1], in: .board)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
try loseTheSnapshot(harness)
|
||||
|
||||
target.select([destinationLane], in: .board)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"])
|
||||
#expect(target.banners.losses.isEmpty, "the degraded paste's loss row is retired")
|
||||
#expect(target.banners.oneShots.count == 1)
|
||||
let error = try #require(target.banners.oneShots.first).error
|
||||
#expect(BannerCenter.headline(for: error) == "Couldn't paste 'First' — the copied content is gone")
|
||||
}
|
||||
|
||||
@Test("A fallback that lost nothing says nothing")
|
||||
func fallbackWithoutAttachmentsIsSilent() async throws {
|
||||
/// **The attachment-less case refuses too**, which is the pivot at its sharpest: under the degraded
|
||||
/// rule this entry pasted *silently* — its content was intact and it had no attachments to lose, so
|
||||
/// nothing was reported. Refuse-don't-degrade does not ask what would have been lost; the bytes the
|
||||
/// paste was to reproduce are gone, so there is nothing honest to write.
|
||||
@Test("An entry with no attachments refuses just the same")
|
||||
func anAttachmentLessEntryRefusesToo() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
// `card2` has no attachments, so a fallback loses nothing at all.
|
||||
harness.store.select([clipboardCard2], in: .board)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
try loseTheSnapshot(harness)
|
||||
|
||||
target.select([destinationLane], in: .board)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
|
||||
#expect(target.banners.losses.isEmpty)
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident"])
|
||||
#expect(target.banners.oneShots.count == 1)
|
||||
}
|
||||
|
||||
@Test("A lane's fallback materializes its embedded cards")
|
||||
func laneFallbackCarriesItsCards() async throws {
|
||||
@Test("A lane payload refuses whole — no lane, no cards")
|
||||
func aLanePayloadRefusesWhole() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
@@ -625,115 +635,93 @@ struct PasteFallbackTests {
|
||||
harness.store.select([clipboardLane1], in: .board)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
let before = try pasted(destination).lanes.map(\.id)
|
||||
try loseTheSnapshot(harness)
|
||||
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let arrived = try #require(try pasted(destination).lanes.last)
|
||||
#expect(arrived.title.value == "Todo")
|
||||
// Both of the lane's cards.
|
||||
#expect(arrived.cards.count == 2)
|
||||
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
|
||||
#expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
|
||||
#expect(try pasted(destination).lanes.map(\.id) == before, "the strip is untouched")
|
||||
let error = try #require(target.banners.oneShots.first).error
|
||||
#expect(BannerCenter.headline(for: error) == "Couldn't paste 'Todo' — the copied content is gone")
|
||||
}
|
||||
|
||||
@Test("A trash-sourced fallback materializes an ordinary card — there is no key to strip")
|
||||
func trashedFallbackIsOrdinary() async throws {
|
||||
/// **All-or-nothing for the whole paste** — the transactional half of the ruling, which the former
|
||||
/// mixed path is exactly what retired: one entry's snapshot going missing used to leave its
|
||||
/// siblings arriving whole beside a hollowed copy of it. Now the gesture refuses as a unit.
|
||||
@Test("One missing snapshot refuses the whole multi-entry paste")
|
||||
func oneMissingEntryRefusesTheWholePaste() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.transient.isTrashVisible = true
|
||||
harness.store.select([clipboardCard3], in: .trash)
|
||||
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
|
||||
// Only the *first* entry's tree is removed; the second is staged and perfectly pasteable.
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
at: harness.staging
|
||||
.appendingPathComponent(copyID, isDirectory: true)
|
||||
.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||
)
|
||||
|
||||
target.select([destinationLane], in: .board)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BoardWriter.materializeItem
|
||||
|
||||
@Suite("BoardWriter ▸ materializeItem")
|
||||
struct MaterializeItemTests {
|
||||
|
||||
@Test("The supplied bytes land verbatim but for the rewritten order and stamps")
|
||||
func writesTheSuppliedBytes() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
|
||||
let id = try BoardWriter.materializeItem(
|
||||
inParent: fixture.url(Ident.lane1),
|
||||
indexText: Item.rich(order: "9999", title: "Pasted"),
|
||||
order: 512
|
||||
#expect(
|
||||
try pastedTitles(destinationLane, in: destination) == ["Resident"],
|
||||
"not even the entry that could have arrived whole"
|
||||
)
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(id.rawValue)"))
|
||||
#expect(document.title.value == "Pasted")
|
||||
#expect(document.order.value == 512)
|
||||
#expect(document.value(for: "project") != nil)
|
||||
#expect(document.value(for: "labels") != nil)
|
||||
// The app-write stamps: `modified` set, `modified-by` cleared.
|
||||
#expect(document.modified.value != ISO8601DateFormatter().date(from: "2026-02-02T09:00:00Z"))
|
||||
#expect(document.modifiedBy.isMissing)
|
||||
// `created` untouched — a paste is a fork.
|
||||
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
|
||||
#expect(target.banners.oneShots.count == 1, "one refusal for one gesture")
|
||||
}
|
||||
|
||||
@Test("Children are materialized under fresh identities and never rewritten")
|
||||
func childrenAreVerbatim() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
let id = try BoardWriter.materializeItem(
|
||||
inParent: fixture.root,
|
||||
indexText: Item.rich(order: "1024", title: "Lane"),
|
||||
children: [Item.rich(order: "1024", title: "One"), Item.uneditable],
|
||||
order: 1024
|
||||
)
|
||||
|
||||
let lane = try #require(try BoardLoader.load(boardRoot: fixture.root).model.lanes.first)
|
||||
#expect(lane.id == id)
|
||||
#expect(lane.cards.count == 2)
|
||||
// An uneditable child arrives exactly as it was — the leniency `copyItem` extends below its
|
||||
// root, applied here.
|
||||
let names = try FileManager.default.contentsOfDirectory(atPath: fixture.url(id.rawValue).path)
|
||||
.filter { $0 != "index.md" }
|
||||
let odd = try #require(names.first { name in
|
||||
(try? fixture.indexText("\(id.rawValue)/\(name)")) == Item.uneditable
|
||||
})
|
||||
#expect(try fixture.indexText("\(id.rawValue)/\(odd)") == Item.uneditable)
|
||||
/// **A refusal costs the user their content *and* nothing else** — the destination's active search
|
||||
/// survives it. "Any user-initiated creation on the board clears the query" (04 ▸ Search) is a rule
|
||||
/// about creations, and a refused paste creates nothing.
|
||||
@Test("A refused paste leaves the destination's search alone")
|
||||
func aRefusalKeepsTheSearch() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
target.transient.searchQuery = "resident"
|
||||
|
||||
harness.store.select([clipboardCard1], in: .board)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
try loseTheSnapshot(harness)
|
||||
|
||||
target.select([destinationLane], in: .board)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(target.transient.searchQuery == "resident")
|
||||
}
|
||||
|
||||
@Test("An unparseable root refuses and leaves nothing behind")
|
||||
func unparseableRootLeavesNoResidue() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
let error = writeFailure {
|
||||
_ = try BoardWriter.materializeItem(
|
||||
inParent: fixture.root,
|
||||
indexText: "no frontmatter here at all\n",
|
||||
order: 1024
|
||||
)
|
||||
}
|
||||
#expect(error != nil)
|
||||
#expect(try fixture.entryNames("") == ["index.md"])
|
||||
/// A cut whose staged snapshot is gone is a different story and stays one: an armed cut moves the
|
||||
/// **originals**, which are real folders in the source board, so it never reads staging at all.
|
||||
/// The refusal is the copy path's, and this pins that it did not spread.
|
||||
@Test("An armed cut still moves its originals — it never reads staging")
|
||||
func anArmedCutIsUnaffected() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], in: .board)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
try loseTheSnapshot(harness)
|
||||
|
||||
target.select([destinationLane], in: .board)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
|
||||
#expect(target.banners.oneShots.isEmpty, "nothing failed — the folder moved")
|
||||
#expect(harness.fixture.exists("\(Ident.lane1)/\(Ident.card1)") == false, "and it left the source")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +528,156 @@ struct TemplateEngineAtomicityTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The copy contract
|
||||
|
||||
/// **Instantiation is a copy transaction, and it severs tracker identity** — the two 2026-07-29
|
||||
/// rulings applied to the flow 01 names alongside paste and the ⌥-drag duplicate (01-storage-format.md
|
||||
/// § Frontmatter's compound-operations clause; § Fractal layout ▸ Rules' item-level sever).
|
||||
@Suite("TemplateEngine — the copy contract")
|
||||
struct TemplateEngineCopyContractTests {
|
||||
|
||||
/// A template carrying a readable-but-uneditable card refuses the **whole** create, naming that
|
||||
/// card, and leaves nothing where the user pointed — the former root-strict/descendants-lenient
|
||||
/// split would have made a board from it with one silently unstamped card inside.
|
||||
@Test("An uneditable card in the template refuses the create, naming it")
|
||||
func anUneditableCardRefusesTheCreate() throws {
|
||||
let template = try FixtureTemplate()
|
||||
defer { template.tearDown() }
|
||||
try template.fixture.item(
|
||||
"\(FixtureTemplate.name)/\(Ident.lane1)/\(Ident.card3)",
|
||||
Item.uneditable
|
||||
)
|
||||
let destination = template.destination()
|
||||
|
||||
let failure = instantiationFailure {
|
||||
try TemplateEngine.instantiate(template: try template.template(), to: destination, title: "Doomed")
|
||||
}
|
||||
guard case let .failed(write) = failure else {
|
||||
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
||||
return
|
||||
}
|
||||
if case .uneditableFrontmatter = write.reason {} else {
|
||||
Issue.record("expected the uneditable-shape refusal, got \(write.reason)")
|
||||
}
|
||||
#expect(write.operation == .createBoard, "the create is what refused")
|
||||
#expect(
|
||||
!FileManager.default.fileExists(atPath: destination.path),
|
||||
"construct-then-clean: the partial destination goes with the refusal"
|
||||
)
|
||||
}
|
||||
|
||||
/// A template whose `.trash/` holds a broken card still instantiates: the preflight runs on the
|
||||
/// **destination**, after the copy applied its exclusions, so a card that was never going to be
|
||||
/// copied cannot refuse the create it has nothing to do with.
|
||||
@Test("An uneditable card in the template's trash refuses nothing — it is never copied")
|
||||
func anUneditableTrashCardIsIrrelevant() throws {
|
||||
let template = try FixtureTemplate()
|
||||
defer { template.tearDown() }
|
||||
try template.fixture.item("\(FixtureTemplate.name)/.trash/\(Ident.card3)", Item.uneditable)
|
||||
let destination = template.destination()
|
||||
|
||||
try TemplateEngine.instantiate(template: try template.template(), to: destination, title: "Fine")
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: destination.appendingPathComponent("index.md").path))
|
||||
#expect(
|
||||
!FileManager.default.fileExists(atPath: destination.appendingPathComponent(".trash").path),
|
||||
"and the trash was excluded, as always"
|
||||
)
|
||||
}
|
||||
|
||||
/// **The tracker sever, at every level an instantiation materializes** — board root, lane, and card.
|
||||
/// A template can carry the keys in from the board it was saved from (Save as Template is a fork and
|
||||
/// keeps them verbatim), and the board born from it must not claim those remote objects.
|
||||
@Test("Instantiation drops the reserved tracker keys at every level")
|
||||
func instantiationSeversTrackerIdentity() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let name = "Tracked.kanban"
|
||||
try fixture.item(name, """
|
||||
---
|
||||
schema: 1
|
||||
title: Tracked Template
|
||||
template: {order: 1}
|
||||
project: lanework
|
||||
remote: gitea#7
|
||||
---
|
||||
Blurb.
|
||||
|
||||
""")
|
||||
try fixture.item("\(name)/\(Ident.lane1)", Item.tracked(order: "1024", title: "To Do", key: "remote-state"))
|
||||
try fixture.item(
|
||||
"\(name)/\(Ident.lane1)/\(Ident.card1)",
|
||||
Item.tracked(order: "1024", title: "Starter", key: "remote")
|
||||
)
|
||||
|
||||
let template: BoardTemplate = switch TemplateEngine.load(templateAt: fixture.url(name), origin: .user) {
|
||||
case let .success(loaded): loaded
|
||||
case let .failure(error): throw error
|
||||
}
|
||||
let destination = fixture.url("Born.kanban")
|
||||
try TemplateEngine.instantiate(template: template, to: destination, title: "Born")
|
||||
|
||||
let board = try FrontmatterDocument.parse(String(
|
||||
decoding: Data(contentsOf: destination.appendingPathComponent("index.md")), as: UTF8.self
|
||||
))
|
||||
#expect(board.value(for: "remote") == nil, "the board born today claims no remote object")
|
||||
#expect(board.value(for: "project") != nil, "and every other unknown key rode along")
|
||||
|
||||
let lanes = ((try? BoardLoader.directoryCandidates(in: destination)) ?? [])
|
||||
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
|
||||
let lane = try FrontmatterDocument.parse(String(
|
||||
decoding: Data(contentsOf: try #require(lanes.first).appendingPathComponent("index.md")), as: UTF8.self
|
||||
))
|
||||
#expect(lane.value(for: "remote-state") == nil)
|
||||
|
||||
let card = try FrontmatterDocument.parse(String(
|
||||
decoding: Data(contentsOf: try #require(cardFolders(under: destination).first)
|
||||
.appendingPathComponent("index.md")), as: UTF8.self
|
||||
))
|
||||
#expect(card.value(for: "remote") == nil)
|
||||
#expect(card.value(for: "project") != nil)
|
||||
}
|
||||
|
||||
/// **Save as Template is a whole-board fork and is exempt** (01 ▸ Identity lifecycle's carve-out):
|
||||
/// it "carries them verbatim", GUIDs, timestamps and tracker keys alike, because a fork is a new
|
||||
/// namespace rather than a second claimant inside one board. The sever belongs to *item-level*
|
||||
/// copies, and this is the line between them.
|
||||
@Test("Save as Template carries the tracker keys verbatim")
|
||||
func saveAsTemplateIsExempt() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("Board.kanban", """
|
||||
---
|
||||
schema: 1
|
||||
title: Live Board
|
||||
remote: gitea#7
|
||||
---
|
||||
Body.
|
||||
|
||||
""")
|
||||
try fixture.item("Board.kanban/\(Ident.lane1)", Item.tracked(order: "1024", title: "To Do", key: "remote-state"))
|
||||
try fixture.item(
|
||||
"Board.kanban/\(Ident.lane1)/\(Ident.card1)",
|
||||
Item.tracked(order: "1024", title: "Card", key: "remote")
|
||||
)
|
||||
let store = fixture.url("Store")
|
||||
|
||||
let saved = try TemplateEngine.saveAsTemplate(
|
||||
boardAt: fixture.url("Board.kanban"), titled: "Live Board", into: store
|
||||
)
|
||||
|
||||
let board = try FrontmatterDocument.parse(String(
|
||||
decoding: Data(contentsOf: saved.appendingPathComponent("index.md")), as: UTF8.self
|
||||
))
|
||||
#expect(board.value(for: "remote") != nil, "a fork carries them verbatim")
|
||||
let lane = try FrontmatterDocument.parse(String(
|
||||
decoding: Data(contentsOf: saved.appendingPathComponent(Ident.lane1).appendingPathComponent("index.md")),
|
||||
as: UTF8.self
|
||||
))
|
||||
#expect(lane.value(for: "remote-state") != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared
|
||||
|
||||
/// Every card folder under an instantiated board — `<root>/<lane>/<card>`, by the loader's own level
|
||||
|
||||
@@ -390,7 +390,7 @@ struct PurgeTests {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([card1], in: .board)
|
||||
|
||||
store.deleteImmediately([card1])
|
||||
store.deleteImmediately([card1], in: .board)
|
||||
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
#expect(!fixture.exists(".trash/\(Ident.card1)"), "03 ▸ Trash: ⌥⌘⌫ skips the trash from anywhere")
|
||||
@@ -404,7 +404,7 @@ struct PurgeTests {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([trashed], in: .trash)
|
||||
|
||||
store.deleteImmediately([trashed])
|
||||
store.deleteImmediately([trashed], in: .trash)
|
||||
|
||||
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
||||
#expect(fixture.exists(".trash/\(More.newer)"), "and only what it named")
|
||||
@@ -417,7 +417,7 @@ struct PurgeTests {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([lane3], in: .board)
|
||||
|
||||
store.deleteImmediately([lane3])
|
||||
store.deleteImmediately([lane3], in: .board)
|
||||
|
||||
#expect(fixture.exists(Ident.lane3))
|
||||
}
|
||||
@@ -473,7 +473,7 @@ struct PurgeTests {
|
||||
|
||||
store.select([trashed], in: .trash)
|
||||
store.deleteTrashCards([trashed])
|
||||
store.deleteImmediately([newer])
|
||||
store.deleteImmediately([newer], in: .trash)
|
||||
store.emptyTrash()
|
||||
|
||||
// 13-native-undo.md ▸ Rules: "Permanently delete (Delete Immediately, Empty Trash) …
|
||||
@@ -745,7 +745,7 @@ struct TrashConfirmationsTests {
|
||||
|
||||
let pending = try #require(confirmations.pending)
|
||||
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
|
||||
#expect(pending.action == .purge([trashed]))
|
||||
#expect(pending.action == .purge([trashed], .trash))
|
||||
// Nothing has happened yet — the alert is what stands between the keystroke and the loss.
|
||||
#expect(fixture.exists(".trash/\(Ident.indexless)"))
|
||||
|
||||
@@ -756,6 +756,72 @@ struct TrashConfirmationsTests {
|
||||
confirmations.confirm(in: store)
|
||||
}
|
||||
|
||||
/// **The card and lane context menus' ⌥-alternate** — Delete Immediately, routed through
|
||||
/// `requestBoardPurge` rather than through `requestPurge` (11-command-nexus.md ▸ Context menus'
|
||||
/// Card and Lane rows: "Delete — with Delete Immediately as its ⌥-alternate").
|
||||
/// `purgeConfirmsThenActs`'s twin for the board side: same alert, same rule, a board card as the
|
||||
/// target instead of a trash one.
|
||||
@Test("The board-side ⌥-alternate raises the same alert, and purges the board card on confirm")
|
||||
func boardPurgeConfirmsThenActs() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
confirmations.requestBoardPurge(of: [card1], in: store)
|
||||
|
||||
let pending = try #require(confirmations.pending)
|
||||
#expect(pending.prompt.title == "Permanently delete \u{201C}First\u{201D}?")
|
||||
#expect(pending.action == .purge([card1], .board))
|
||||
// Nothing has happened yet — same alert, same rule.
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
|
||||
confirmations.confirm(in: store)
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
#expect(!fixture.exists(".trash/\(Ident.card1)"), "skips the trash — purged, not moved")
|
||||
#expect(confirmations.pending == nil)
|
||||
}
|
||||
|
||||
/// A context menu names its target by where it was invoked, so a card row's Delete Immediately
|
||||
/// must purge the *clicked* card even while a different card is selected — `TrashMenuValidation
|
||||
/// Tests.contextMenuDeleteIgnoresTheSelection`'s claim, mirrored onto the board side.
|
||||
@Test("The board-side ⌥-alternate acts on its own target, not the standing selection")
|
||||
func boardPurgeIgnoresTheSelection() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
// A right-click on `card1` without first selecting it must still purge `card1`, never the
|
||||
// card the standing selection happens to hold (`CardFaceView.targetIDs`'s targeting rule).
|
||||
store.select([card2], in: .board)
|
||||
|
||||
confirmations.requestBoardPurge(of: [card1], in: store)
|
||||
let pending = try #require(confirmations.pending)
|
||||
#expect(pending.action == .purge([card1], .board))
|
||||
|
||||
confirmations.confirm(in: store)
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"), "the clicked card is gone")
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "the selected card was never the subject")
|
||||
}
|
||||
|
||||
/// `TrashModel.canDeleteImmediately` is cards only (`TrashValidationTests
|
||||
/// .canDeleteImmediatelyIsCardsOnly`: "a lane's delete is physical already … there is nothing for
|
||||
/// 'skip the trash' to mean on one"), and the lane row's alternate inherits that unchanged: it is
|
||||
/// wired per 11-command-nexus.md's Lane row, but presently inert on a lane-only target — the same
|
||||
/// posture File ▸ Delete Immediately already takes on a lane-only selection.
|
||||
@Test("A lane-only target raises no prompt — the alternate is still cards only")
|
||||
func boardPurgeIsStillCardsOnlyForALaneTarget() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
confirmations.requestBoardPurge(of: [lane1], in: store)
|
||||
|
||||
#expect(confirmations.pending == nil)
|
||||
#expect(fixture.exists(Ident.lane1))
|
||||
}
|
||||
|
||||
/// 03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent … Both confirm exactly
|
||||
/// where the loss is real."
|
||||
@Test("The trash's own Delete confirms; the board's goes straight through")
|
||||
|
||||
@@ -487,6 +487,91 @@ struct MoveUndoTests {
|
||||
#expect(try document(fixture, Ident.lane1).order.value == moved)
|
||||
}
|
||||
|
||||
/// **The inverses conform to the container-change predicate** (01-storage-format.md
|
||||
/// § Frontmatter ▸ `modified`'s scope, refined 2026-07-30) — the m8 conformance check, stated at
|
||||
/// the level the rule is about: an inverse is an ordinary app-mediated write, so it is subject to
|
||||
/// the *same* predicate as the gesture it inverts, not to a rule of its own.
|
||||
///
|
||||
/// Three claims in one round trip, because they are one claim: the undo of a within-lane reorder is
|
||||
/// itself a within-lane reorder and rewrites only `order`; the undo of a cross-lane move is itself a
|
||||
/// cross-lane move and stamps; and **no trash-specific branch exists in either direction** — the
|
||||
/// trash round trip stamps for the same reason the cross-lane one does.
|
||||
///
|
||||
/// It reads `modified-by` rather than `modified`, deliberately: `untouchedLines` filters the whole
|
||||
/// `modified*` family precisely because a content write is *expected* to move it, so the foreign
|
||||
/// stamp's survival is the assertion with a sharp edge — it survives an order-only rewrite and is
|
||||
/// cleared by a content one, and `Item.rich` plants one on every fixture card for exactly this.
|
||||
@Test("An inverse stamps only when it changes a container")
|
||||
func inversesFollowTheContainerPredicate() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
// Within-lane, there and back: nothing on either leg is a content write.
|
||||
store.moveCards([card1], toLane: lane1, at: 2)
|
||||
#expect(try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
|
||||
history.undo()
|
||||
#expect(
|
||||
try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == "claude",
|
||||
"undoing a reorder is a reorder — order-only, both ways"
|
||||
)
|
||||
|
||||
// Cross-lane, there and back: both legs change the container, so both stamp.
|
||||
store.moveCards([card2], toLane: lane2, at: 0)
|
||||
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card2)").rawValue(for: FrontmatterKeys.modifiedBy) == nil)
|
||||
// Re-planted by hand, standing in for an agent that stamped the card in its new lane — the
|
||||
// inverse has to clear it again, because moving back is itself a container change.
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card2)"), operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) }
|
||||
history.undo()
|
||||
#expect(
|
||||
try document(fixture, card2Path).rawValue(for: FrontmatterKeys.modifiedBy) == nil,
|
||||
"undoing a cross-lane move is a cross-lane move — it stamps"
|
||||
)
|
||||
}
|
||||
|
||||
/// The lane half of the same claim: a lane's container is the board root and never changes, so a
|
||||
/// lane drag and its inverse are both order-only.
|
||||
@Test("A lane reorder and its inverse are both order-only")
|
||||
func laneInversesAreOrderOnly() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.moveLane(lane1, toIndex: 1)
|
||||
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
|
||||
history.undo()
|
||||
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
|
||||
history.redo()
|
||||
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
|
||||
}
|
||||
|
||||
/// The trash round trip, from the undo stack rather than the Writer: the delete stamps and its
|
||||
/// inverse — the move back out — stamps too. **Neither is a special case**; both are container
|
||||
/// changes, which is the whole of the refinement.
|
||||
@Test("A delete and its inverse both stamp, with no trash branch")
|
||||
func theTrashRoundTripStampsBothWays() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.select([card1], in: .board)
|
||||
store.deleteSelection()
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
#expect(try document(fixture, ".trash/\(Ident.card1)").rawValue(for: FrontmatterKeys.modifiedBy) == nil)
|
||||
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url(".trash/\(Ident.card1)"), operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) }
|
||||
history.undo()
|
||||
#expect(fixture.exists(card1Path))
|
||||
#expect(
|
||||
try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == nil,
|
||||
"restoring out of the trash is a container change and clears the stamp"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("⌥⌘↓ undoes the whole permutation, siblings included")
|
||||
func sortRoundTrip() throws {
|
||||
let fixture = try makeBoard()
|
||||
@@ -735,7 +820,7 @@ struct NotUndoableTests {
|
||||
let armed = try #require(history.undoActionName)
|
||||
|
||||
store.select([trashed], in: .trash)
|
||||
store.deleteImmediately([trashed])
|
||||
store.deleteImmediately([trashed], in: .trash)
|
||||
|
||||
#expect(fixture.exists(trashedPath) == false)
|
||||
#expect(store.purgeIsUnrecoverable)
|
||||
|
||||
@@ -393,3 +393,203 @@ struct WriteFidelityCompositeTests {
|
||||
#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, order: 1024
|
||||
)
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,27 @@ enum Item {
|
||||
/// A whole-frontmatter flow mapping carrying a `modified-by`: readable, uneditable, and so
|
||||
/// left byte-verbatim by a copy — stale attribution included.
|
||||
static let uneditable = "---\n{schema: 1, order: 1024, title: Odd, modified-by: claude}\n---\nodd body\n"
|
||||
|
||||
/// An item carrying one of the **reserved tracker keys** — `remote` on a board or card,
|
||||
/// `remote-state` on a lane (01-storage-format.md § Enhanced schema) — beside an ordinary unknown
|
||||
/// key, so a copy's tracker sever can be told apart from unknown-key preservation breaking.
|
||||
///
|
||||
/// Nothing in this version reads the keys; what the suites pin is that an **item-level copy drops
|
||||
/// them** (ruled 2026-07-29) while a whole-board fork carries them verbatim.
|
||||
static func tracked(order: String, title: String, key: String) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
project: lanework # agent overlay
|
||||
\(key): gitea#42
|
||||
created: 2026-01-01T09:00:00Z
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Failure assertion
|
||||
|
||||
Reference in New Issue
Block a user