Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two containers: CardFaceView extracted with a role — board or trash — so stripe, tint, chip, selection stroke, cut dim, marquee registration, and drag are shared by construction, the trash side differing only in its absences: no Open, no rename, no Style, no file-hover highlight, and a Delete that goes through the confirmation host. The column rewrote around the lanes' own single-column masonry so drag reflow reads as positional slides; chrome stays the hatched header, symbol, and count — 11 gives Empty Trash to the File menu alone. Two real grammar bugs die here: plain Backspace on a trash selection purged without the confirmation the menu raises, and the context menu's Delete resolved against the standing selection, so right-clicking a trash card under a board selection silently did nothing — it now stages the clicked set explicitly. Open, Rename, Style, and Empty Trash validation became testable store seams; the column is one named accessibility container of ordinary card elements. The tombstone era is swept: deleteItem, restoreItem, stripTombstonedChildren — dead since lane copies stopped nesting trash — the restore verb, the unreachable put-back banner row, and every quasi-lane doc comment. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -26,9 +26,7 @@ import Testing
|
||||
/// 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; delete→restore at full byte precision against the pre-delete original
|
||||
/// (existing coverage checks the *result* is undeleted and reordered correctly, not that the
|
||||
/// bytes differ from the original by exactly one line); unknown-key order through a writer op
|
||||
/// 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.
|
||||
|
||||
@@ -110,8 +108,19 @@ struct WriteFidelityMinimalTouchTests {
|
||||
snapshots[folder] = try snapshot(fixture, folder)
|
||||
}
|
||||
|
||||
func step(_ label: String, targeting targets: Set<String>, _ operation: () throws -> Void) throws {
|
||||
/// `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)
|
||||
@@ -144,11 +153,24 @@ struct WriteFidelityMinimalTouchTests {
|
||||
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) }
|
||||
}
|
||||
try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
||||
try BoardWriter.deleteItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
|
||||
// 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, order: 1024
|
||||
)
|
||||
}
|
||||
try step("restore", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
||||
try BoardWriter.restoreItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
|
||||
// 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 = ""
|
||||
@@ -219,65 +241,6 @@ struct WriteFidelityRenumberTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete → restore, byte precision
|
||||
|
||||
/// 01-storage-format.md § Deletion: Put Back "undoes exactly what `deleteItem` wrote". Existing
|
||||
/// coverage (`BoardWriterDeleteRestoreTests`) checks the *result* — undeleted, reordered
|
||||
/// correctly — this test checks the *bytes*: after a full delete→restore round trip, the file
|
||||
/// differs from the pre-delete original in exactly one place, the `modified:` line, with every
|
||||
/// comment, inline comment, unknown key, and per-line ending untouched, and zero occurrences of
|
||||
/// `deleted` anywhere in the text.
|
||||
struct WriteFidelityTombstoneTests {
|
||||
@Test func deleteThenRestoreDiffersFromTheOriginalOnlyInTheModifiedTimestamp() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||
let original = "---\n"
|
||||
+ "schema: 1\n"
|
||||
+ "# a hand-written note\n"
|
||||
+ "title: Original\n"
|
||||
+ "order: 1536\n"
|
||||
+ "project: lanework # agent overlay\n"
|
||||
+ "sphere: work\r\n"
|
||||
+ "labels: [a, b, c]\n"
|
||||
+ "modified: 2026-01-01T00:00:00Z\n"
|
||||
+ "---\n"
|
||||
+ "Body text.\n\nMore body — with *markdown*.\n"
|
||||
let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
||||
let folder = try fixture.item(cardPath, original)
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", "---\nschema: 1\norder: 2048\ntitle: Sibling\n---\n")
|
||||
|
||||
try BoardWriter.deleteItem(at: folder)
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
|
||||
|
||||
try BoardWriter.restoreItem(at: folder)
|
||||
let after = try fixture.indexText(cardPath)
|
||||
|
||||
#expect(lines(of: after, excludingKeys: [FrontmatterKeys.modified])
|
||||
== lines(of: original, excludingKeys: [FrontmatterKeys.modified]))
|
||||
// The untouched CRLF unknown-key line and the inline comment both survived verbatim.
|
||||
#expect(after.contains("sphere: work\r\n"))
|
||||
#expect(after.contains("project: lanework # agent overlay\n"))
|
||||
// No residue of the key that made this item a tombstone, anywhere in the text.
|
||||
#expect(!after.contains("deleted"))
|
||||
|
||||
let document = try FrontmatterDocument.parse(after)
|
||||
#expect(document.deleted == .missing)
|
||||
#expect(document.order == .valid(1536))
|
||||
#expect(document.title == .valid("Original"))
|
||||
|
||||
// Position among siblings unchanged: the loader sees the card back at its recorded
|
||||
// order, ahead of the sibling that was never touched.
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.warnings.isEmpty)
|
||||
let cards = try #require(result.model.lanes.first?.cards)
|
||||
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
|
||||
#expect(cards[0].isDeleted == false)
|
||||
#expect(cards[0].order == 1536)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unknown-key order through a writer op
|
||||
|
||||
/// 01-storage-format.md § Fractal layout ▸ Rules, "unknown frontmatter keys and their order are
|
||||
@@ -380,10 +343,19 @@ struct WriteFidelityCompositeTests {
|
||||
)
|
||||
#expect(copyID != card3)
|
||||
|
||||
// Delete, then restore, the hand-edited card — its unknown keys and comment must come
|
||||
// back with no residue of `deleted`.
|
||||
try BoardWriter.deleteItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
|
||||
try BoardWriter.restoreItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
|
||||
// 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, order: 1024
|
||||
)
|
||||
_ = 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)
|
||||
|
||||
Reference in New Issue
Block a user