Implement move and copy identity semantics
moveItem: physical folder move, UUID and created travel unchanged; exactly one file rewritten (the moved root's order, stamped). Import boundary detected by resolved board-root comparison; on a cross-board move-in, arriving UUIDs colliding with any identity in the destination board (tombstones included) are reminted per folder at the finest grain — folder rename only, file bytes untouched, every repair reported in MoveResult. A colliding root moves straight to its minted name. A same-parent move degrades to a plain reorder, self excluded from the appended-rank scan. copyItem: whole-tree copy minting fresh UUIDs at every depth; .fork keeps created while stamping modified and clearing modified-by, .born (template instantiation) stamps created fresh too. Nested uneditable or unreadable files copy byte-verbatim rather than blocking the gesture; the root must be rewritable. All-or-nothing at the destination — any failure removes the partial tree. 24 new unit tests; 220 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -56,6 +56,24 @@ private struct WriterFixture {
|
||||
return folder
|
||||
}
|
||||
|
||||
/// Writes an arbitrary file — not an `index.md` — creating its folder: attachments and
|
||||
/// strays, the content a copy has to carry verbatim without ever reading it.
|
||||
@discardableResult
|
||||
func file(_ relativePath: String, _ bytes: Data) throws -> URL {
|
||||
let fileURL = root.appendingPathComponent(relativePath)
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try bytes.write(to: fileURL)
|
||||
return fileURL
|
||||
}
|
||||
|
||||
func data(_ relativePath: String) throws -> Data {
|
||||
try Data(contentsOf: root.appendingPathComponent(relativePath))
|
||||
}
|
||||
|
||||
func exists(_ relativePath: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: url(relativePath).path)
|
||||
}
|
||||
|
||||
func indexData(_ relativePath: String) throws -> Data {
|
||||
try Data(contentsOf: url(relativePath).appendingPathComponent("index.md"))
|
||||
}
|
||||
@@ -122,6 +140,67 @@ private enum Child {
|
||||
static let indexless = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
|
||||
}
|
||||
|
||||
/// Literal UUID-shaped names for the move/copy suites, which need more of them than `Child`
|
||||
/// offers — an import-boundary test has the *same* identity living in two boards at once, and a
|
||||
/// compound arrival needs a lane with several cards.
|
||||
private enum Ident {
|
||||
static let lane1 = "11111111-1111-4111-8111-111111111111"
|
||||
static let lane2 = "22222222-2222-4222-8222-222222222222"
|
||||
static let lane3 = "33333333-3333-4333-8333-333333333333"
|
||||
static let lane4 = "44444444-4444-4444-8444-444444444444"
|
||||
static let card1 = "55555555-5555-4555-8555-555555555555"
|
||||
static let card2 = "66666666-6666-4666-8666-666666666666"
|
||||
static let card3 = "77777777-7777-4777-8777-777777777777"
|
||||
static let card4 = "99999999-9999-4999-8999-999999999999"
|
||||
static let indexless = "88888888-8888-4888-8888-888888888888"
|
||||
}
|
||||
|
||||
/// The `index.md` texts the move/copy suites move and copy around.
|
||||
private enum Item {
|
||||
static let board = "---\nschema: 1\ntitle: Board\n---\nBoard description.\n"
|
||||
|
||||
/// Everything a move or a copy has to leave alone: unknown keys with an inline comment, a
|
||||
/// `created` stamp from before today, a foreign `modified-by`, and a body.
|
||||
static func rich(order: String, title: String) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
project: lanework # agent overlay
|
||||
labels: [a, b, c]
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
---
|
||||
\(title) body — with *markdown*.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// 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"
|
||||
}
|
||||
|
||||
/// The keys a move or a copy is allowed to have touched; every other line must be byte-identical.
|
||||
private let rewrittenKeys = [FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
|
||||
|
||||
/// A folder's UUID-shaped children keyed by the `title` inside them. A copy remints every folder
|
||||
/// it materializes, so the file's own content is the only way back to "which card is which".
|
||||
private func childrenByTitle(of relativePath: String, in fixture: WriterFixture) throws -> [String: String] {
|
||||
var byTitle: [String: String] = [:]
|
||||
for child in try BoardLoader.directoryCandidates(in: fixture.url(relativePath))
|
||||
where BoardLoader.isUUIDShaped(child.lastPathComponent) {
|
||||
let name = child.lastPathComponent
|
||||
guard fixture.exists("\(relativePath)/\(name)/index.md") else { continue }
|
||||
if let title = try FrontmatterDocument.parse(fixture.indexText("\(relativePath)/\(name)")).title.value {
|
||||
byTitle[title] = name
|
||||
}
|
||||
}
|
||||
return byTitle
|
||||
}
|
||||
|
||||
private func writeFailure(_ operation: () throws -> Void) -> BoardWriteError? {
|
||||
do {
|
||||
try operation()
|
||||
@@ -920,3 +999,657 @@ struct BoardWriterEditabilityScopeTests {
|
||||
#expect(try fixture.indexText(Child.a) == Self.flowSibling)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Move
|
||||
|
||||
/// 01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves keep the UUID,
|
||||
/// copies mint fresh ones" — the move half, including the import boundary where a colliding
|
||||
/// UUID is degraded to a copy.
|
||||
struct BoardWriterMoveTests {
|
||||
/// Board `A.kanban`: two lanes, each holding one card, everything rich enough that a
|
||||
/// byte-level assertion means something.
|
||||
private func boardA(_ fixture: WriterFixture) throws {
|
||||
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)", Item.rich(order: "1024", title: "Card One"))
|
||||
try fixture.item("A.kanban/\(Ident.lane2)", Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("A.kanban/\(Ident.lane2)/\(Ident.card2)", Item.rich(order: "1024", title: "Card Two"))
|
||||
}
|
||||
|
||||
/// Board `B.kanban`: a separate identity namespace with two lanes of its own, one of them
|
||||
/// holding a card.
|
||||
private func boardB(_ fixture: WriterFixture) throws {
|
||||
try fixture.item("B.kanban", Item.board)
|
||||
try fixture.item("B.kanban/\(Ident.lane3)", Item.rich(order: "1024", title: "Inbox"))
|
||||
try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card3)", Item.rich(order: "1024", title: "Card Three"))
|
||||
try fixture.item("B.kanban/\(Ident.lane4)", Item.rich(order: "2048", title: "Done"))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func move(
|
||||
_ fixture: WriterFixture,
|
||||
_ source: String,
|
||||
to destination: String,
|
||||
from sourceBoard: String = "A.kanban",
|
||||
into destinationBoard: String = "A.kanban",
|
||||
order: Double? = nil
|
||||
) throws -> MoveResult {
|
||||
try BoardWriter.moveItem(
|
||||
at: fixture.url(source),
|
||||
toParent: fixture.url(destination),
|
||||
sourceBoardRoot: fixture.url(sourceBoard),
|
||||
destinationBoardRoot: fixture.url(destinationBoard),
|
||||
order: order
|
||||
)
|
||||
}
|
||||
|
||||
/// The everyday move: a card to another lane in the same board. The folder travels whole,
|
||||
/// its identity travels with it, and the only lines that may differ are the ones the
|
||||
/// destination `order` and the app-write stamps own.
|
||||
@Test func aCrossLaneMoveKeepsTheIdentityAndRewritesOnlyTheOrderAndStamps() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||
let siblingBefore = try fixture.indexData("A.kanban/\(Ident.lane2)/\(Ident.card2)")
|
||||
|
||||
let result = try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)")
|
||||
|
||||
#expect(result.id.rawValue == Ident.card1)
|
||||
#expect(result.reminted.isEmpty)
|
||||
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
|
||||
|
||||
let after = try fixture.indexText("A.kanban/\(Ident.lane2)/\(Ident.card1)")
|
||||
#expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys))
|
||||
|
||||
let document = try FrontmatterDocument.parse(after)
|
||||
let source = try FrontmatterDocument.parse(before)
|
||||
#expect(document.order == .valid(2048))
|
||||
#expect(document.created == source.created)
|
||||
#expect(document.modifiedBy == .missing)
|
||||
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
|
||||
#expect(document.unknownFields.map(\.key) == ["project", "labels"])
|
||||
#expect(after.contains("project: lanework # agent overlay\n"))
|
||||
|
||||
// The move rewrites exactly one file: a destination sibling is not even opened.
|
||||
#expect(try fixture.indexData("A.kanban/\(Ident.lane2)/\(Ident.card2)") == siblingBefore)
|
||||
#expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty)
|
||||
}
|
||||
|
||||
@Test func anExplicitOrderIsWrittenVerbatim() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
|
||||
try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)", order: 1536)
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane2)/\(Ident.card1)"))
|
||||
#expect(document.order == .valid(1536))
|
||||
}
|
||||
|
||||
/// Boards are independent identity namespaces: an import that collides with nothing is an
|
||||
/// ordinary move, UUID and `created` intact.
|
||||
@Test func aCrossBoardMoveWithoutACollisionKeepsTheIdentity() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try boardB(fixture)
|
||||
let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
let result = try move(
|
||||
fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)",
|
||||
to: "B.kanban/\(Ident.lane3)", into: "B.kanban"
|
||||
)
|
||||
|
||||
#expect(result.id.rawValue == Ident.card1)
|
||||
#expect(result.reminted.isEmpty)
|
||||
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
||||
|
||||
let after = try fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)")
|
||||
#expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys))
|
||||
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
|
||||
|
||||
let result2 = try BoardLoader.load(boardRoot: fixture.url("B.kanban"))
|
||||
#expect(result2.warnings.isEmpty)
|
||||
#expect(result2.model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card3, Ident.card1])
|
||||
}
|
||||
|
||||
/// The import boundary: the arriving UUID already exists elsewhere in the destination board,
|
||||
/// so it is degraded to a copy — fresh identity, content untouched, source gone as for any
|
||||
/// move.
|
||||
@Test func anArrivingIdentityTheDestinationBoardAlreadyHoldsIsReminted() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try boardB(fixture)
|
||||
try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Stale Twin"))
|
||||
let twinBefore = try fixture.indexData("B.kanban/\(Ident.lane3)/\(Ident.card1)")
|
||||
let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
let result = try move(
|
||||
fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)",
|
||||
to: "B.kanban/\(Ident.lane4)", into: "B.kanban"
|
||||
)
|
||||
|
||||
let minted = result.id.rawValue
|
||||
#expect(BoardLoader.isUUIDShaped(minted))
|
||||
#expect(minted != Ident.card1)
|
||||
#expect(result.reminted == [MoveResult.Remint(from: ItemID(rawValue: Ident.card1), to: ItemID(rawValue: minted))])
|
||||
|
||||
// Content arrived intact — a remint renames a folder, it does not edit a file.
|
||||
let after = try fixture.indexText("B.kanban/\(Ident.lane4)/\(minted)")
|
||||
#expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys))
|
||||
#expect(try FrontmatterDocument.parse(after).title == .valid("Card One"))
|
||||
|
||||
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
||||
#expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(Ident.card1)") == twinBefore)
|
||||
}
|
||||
|
||||
/// The collision sitting in the very lane being dropped into — the case a move-then-rename
|
||||
/// could not repair, because the plain move would fail on the existing name.
|
||||
@Test func aCollisionInTheDestinationParentItselfIsStillReminted() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try boardB(fixture)
|
||||
try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Stale Twin"))
|
||||
|
||||
let result = try move(
|
||||
fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)",
|
||||
to: "B.kanban/\(Ident.lane3)", into: "B.kanban"
|
||||
)
|
||||
|
||||
#expect(result.id.rawValue != Ident.card1)
|
||||
#expect(result.reminted.count == 1)
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(result.id.rawValue)")).title
|
||||
== .valid("Card One"))
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)")).title
|
||||
== .valid("Stale Twin"))
|
||||
}
|
||||
|
||||
/// Degradation is per folder at the finest grain: a lane arriving with one colliding card is
|
||||
/// still a lane *move*, and only that card is reminted — its bytes untouched, its
|
||||
/// non-colliding siblings' identities intact.
|
||||
@Test func aLaneMoveRemintsOnlyTheCollidingCard() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try boardB(fixture)
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card4)", Item.rich(order: "3072", title: "Card Four"))
|
||||
// Only this one already exists over in B.
|
||||
try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card2)", Item.rich(order: "2048", title: "B's Own"))
|
||||
let laneBefore = try fixture.indexText("A.kanban/\(Ident.lane1)")
|
||||
let collidingBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card2)")
|
||||
let keptBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
let result = try move(fixture, "A.kanban/\(Ident.lane1)", to: "B.kanban", into: "B.kanban")
|
||||
|
||||
#expect(result.id.rawValue == Ident.lane1)
|
||||
#expect(result.reminted.map(\.from.rawValue) == [Ident.card2])
|
||||
let minted = try #require(result.reminted.first?.to.rawValue)
|
||||
#expect(BoardLoader.isUUIDShaped(minted))
|
||||
|
||||
// The repair is folder-name-only: the reminted card's file is byte-identical.
|
||||
#expect(try fixture.indexData("B.kanban/\(Ident.lane1)/\(minted)") == collidingBefore)
|
||||
#expect(try fixture.indexData("B.kanban/\(Ident.lane1)/\(Ident.card1)") == keptBefore)
|
||||
#expect(fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card4)"))
|
||||
#expect(!fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card2)"))
|
||||
|
||||
// The lane's own index.md is the one file the move rewrote.
|
||||
let laneAfter = try fixture.indexText("B.kanban/\(Ident.lane1)")
|
||||
#expect(lines(of: laneAfter, excludingKeys: rewrittenKeys) == lines(of: laneBefore, excludingKeys: rewrittenKeys))
|
||||
#expect(try FrontmatterDocument.parse(laneAfter).order == .valid(3072))
|
||||
#expect(try FrontmatterDocument.parse(laneAfter).modifiedBy == .missing)
|
||||
|
||||
#expect(!fixture.exists("A.kanban/\(Ident.lane1)"))
|
||||
#expect(try BoardLoader.load(boardRoot: fixture.url("B.kanban")).warnings.isEmpty)
|
||||
}
|
||||
|
||||
/// Tombstones are on disk, so they are identities: arriving on top of one would be exactly
|
||||
/// the duplicate the import boundary exists to prevent.
|
||||
@Test func aCollisionWithATombstonedDestinationItemStillRemints() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try boardB(fixture)
|
||||
try fixture.item(
|
||||
"B.kanban/\(Ident.lane4)/\(Ident.card1)",
|
||||
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
|
||||
let result = try move(
|
||||
fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)",
|
||||
to: "B.kanban/\(Ident.lane3)", into: "B.kanban"
|
||||
)
|
||||
|
||||
#expect(result.id.rawValue != Ident.card1)
|
||||
#expect(result.reminted.map(\.from.rawValue) == [Ident.card1])
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane4)/\(Ident.card1)")).deleted.value != nil)
|
||||
}
|
||||
|
||||
/// Boards are independent identity namespaces: the same UUID living in another board is not
|
||||
/// this move's business — only an import boundary ever looks.
|
||||
@Test func aSameBoardMoveNeverRemintsEvenWhenAnotherBoardSharesTheUUID() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try boardB(fixture)
|
||||
try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Fork"))
|
||||
|
||||
let result = try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)")
|
||||
|
||||
#expect(result.id.rawValue == Ident.card1)
|
||||
#expect(result.reminted.isEmpty)
|
||||
#expect(fixture.exists("A.kanban/\(Ident.lane2)/\(Ident.card1)"))
|
||||
#expect(fixture.exists("B.kanban/\(Ident.lane3)/\(Ident.card1)"))
|
||||
}
|
||||
|
||||
/// Discover before you write: the move has to rewrite the moved item's `order`, so a file it
|
||||
/// cannot round-trip refuses the gesture while the folder is still where it was.
|
||||
@Test func anUneditableItemRefusesTheMoveAndTheFolderDoesNotTravel() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
|
||||
|
||||
let error = writeFailure {
|
||||
try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card3)", to: "A.kanban/\(Ident.lane2)")
|
||||
}
|
||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||
#expect(error?.operation == "move item")
|
||||
#expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card3)") == Item.uneditable)
|
||||
#expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == [Ident.card2, "index.md"])
|
||||
}
|
||||
|
||||
@Test func aMissingDestinationParentRefusesTheMove() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
let before = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
let error = writeFailure {
|
||||
try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/nowhere")
|
||||
}
|
||||
guard case .unreadable = error?.reason else {
|
||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
#expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == before)
|
||||
}
|
||||
|
||||
@Test func aDestinationParentThatIsAFileRefusesTheMove() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try fixture.file("A.kanban/notes.txt", Data("x".utf8))
|
||||
|
||||
let error = writeFailure {
|
||||
try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/notes.txt")
|
||||
}
|
||||
guard case .unreadable = error?.reason else {
|
||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
||||
}
|
||||
|
||||
/// Level detection is by name shape, so a stray is not an item: moving one would invent an
|
||||
/// identity the loader would go on ignoring.
|
||||
@Test func aStrayFolderIsNotAnItemAndCannotBeMoved() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try boardA(fixture)
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/notes", Item.rich(order: "1024", title: "Stray"))
|
||||
|
||||
let error = writeFailure {
|
||||
try move(fixture, "A.kanban/\(Ident.lane1)/notes", to: "A.kanban/\(Ident.lane2)")
|
||||
}
|
||||
guard case let .unreadable(message) = error?.reason else {
|
||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("UUID-shaped"))
|
||||
#expect(fixture.exists("A.kanban/\(Ident.lane1)/notes"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Copy
|
||||
|
||||
/// 01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle" — the copy half: fresh
|
||||
/// UUIDs for every folder materialized, `created` kept (a fork) or restamped (born from a
|
||||
/// template), `modified-by` cleared because a copy is an app write.
|
||||
struct BoardWriterCopyTests {
|
||||
/// One board, one lane, two cards — the source of every copy below.
|
||||
private func board(_ fixture: WriterFixture) throws {
|
||||
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)", Item.rich(order: "1024", title: "Card One"))
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
|
||||
try fixture.item("A.kanban/\(Ident.lane2)", Item.rich(order: "2048", title: "Doing"))
|
||||
}
|
||||
|
||||
/// Content that must travel verbatim because the copy never reads it: two attachments (one
|
||||
/// in a subfolder the app never creates but preserves) and a stray file.
|
||||
private func attach(_ fixture: WriterFixture, to cardPath: String) throws {
|
||||
try fixture.file("\(cardPath)/attachments/sketch.png", Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF]))
|
||||
try fixture.file("\(cardPath)/attachments/sub/deep.bin", Data([0x00, 0x01, 0x02, 0xFE]))
|
||||
try fixture.file("\(cardPath)/notes.txt", Data("hand-written\n".utf8))
|
||||
}
|
||||
|
||||
/// The ⌥-drag duplicate: a card copied beside itself. Fresh identity, `created` kept,
|
||||
/// `modified` stamped, `modified-by` cleared, everything else byte-identical.
|
||||
@Test func aCardCopyMintsAFreshIdentityAndForksTheStamps() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
let source = "A.kanban/\(Ident.lane1)/\(Ident.card1)"
|
||||
let before = try fixture.indexData(source)
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url(source),
|
||||
toParent: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
#expect(BoardLoader.isUUIDShaped(id.rawValue))
|
||||
#expect(id.rawValue != Ident.card1)
|
||||
|
||||
let copy = try fixture.indexText("A.kanban/\(Ident.lane1)/\(id.rawValue)")
|
||||
let original = String(decoding: before, as: UTF8.self)
|
||||
#expect(lines(of: copy, excludingKeys: rewrittenKeys) == lines(of: original, excludingKeys: rewrittenKeys))
|
||||
|
||||
let document = try FrontmatterDocument.parse(copy)
|
||||
let sourceDocument = try FrontmatterDocument.parse(original)
|
||||
#expect(document.order == .valid(3072))
|
||||
#expect(document.created == sourceDocument.created)
|
||||
#expect(document.modifiedBy == .missing)
|
||||
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
|
||||
#expect(document.unknownFields.map(\.key) == ["project", "labels"])
|
||||
#expect(document.body == "Card One body — with *markdown*.\n")
|
||||
|
||||
// The source is never touched, on any path.
|
||||
#expect(try fixture.indexData(source) == before)
|
||||
}
|
||||
|
||||
@Test func anExplicitOrderIsWrittenVerbatim() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"),
|
||||
toParent: fixture.url("A.kanban/\(Ident.lane2)"),
|
||||
order: 512,
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane2)/\(id.rawValue)"))
|
||||
#expect(document.order == .valid(512))
|
||||
}
|
||||
|
||||
/// A copied lane's cards are new cards, not the same cards seen twice: every folder the copy
|
||||
/// materialized has a fresh identity, at every depth.
|
||||
@Test func aLaneCopyMintsFreshIdentitiesAtEveryLevel() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
toParent: fixture.url("A.kanban"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
#expect(id.rawValue != Ident.lane1)
|
||||
let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture)
|
||||
#expect(Set(copied.keys) == ["Card One", "Card Two"])
|
||||
#expect(Set(copied.values).isDisjoint(with: [Ident.card1, Ident.card2]))
|
||||
#expect(copied.values.allSatisfy(BoardLoader.isUUIDShaped))
|
||||
|
||||
// Ranks travel with the cards; only the copied *root* gets a new one.
|
||||
let one = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card One"]))"))
|
||||
let two = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card Two"]))"))
|
||||
#expect(one.order == .valid(1024))
|
||||
#expect(two.order == .valid(2048))
|
||||
#expect(one.created == .valid(try #require(FrontmatterDocument.parse(Item.rich(order: "1024", title: "x")).created.value)))
|
||||
#expect(one.modifiedBy == .missing)
|
||||
#expect(two.modifiedBy == .missing)
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)")).order == .valid(3072))
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
|
||||
#expect(result.warnings.isEmpty)
|
||||
#expect(result.model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, id.rawValue])
|
||||
}
|
||||
|
||||
/// Attachments, their subfolders, and strays travel byte-for-byte — the copy never opens
|
||||
/// them, so there is nothing to get wrong.
|
||||
@Test func attachmentsSubfoldersAndStraysTravelByteIdentically() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
try attach(fixture, to: "A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
toParent: fixture.url("A.kanban"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture)
|
||||
let card = try #require(copied["Card One"])
|
||||
for path in ["attachments/sketch.png", "attachments/sub/deep.bin", "notes.txt"] {
|
||||
#expect(
|
||||
try fixture.data("A.kanban/\(id.rawValue)/\(card)/\(path)")
|
||||
== fixture.data("A.kanban/\(Ident.lane1)/\(Ident.card1)/\(path)")
|
||||
)
|
||||
}
|
||||
// `attachments/` is not UUID-shaped, so the remint walk never descends into it.
|
||||
#expect(try fixture.entryNames("A.kanban/\(id.rawValue)/\(card)").contains("attachments"))
|
||||
}
|
||||
|
||||
/// Template instantiation: born today, not forked — `created` and `modified` both fresh, and
|
||||
/// the same `Date` for the whole tree.
|
||||
@Test func bornStampsCreatedAndModifiedFreshAtEveryLevel() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
toParent: fixture.url("A.kanban"),
|
||||
order: nil,
|
||||
stamps: .born
|
||||
)
|
||||
|
||||
let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture)
|
||||
let paths = ["A.kanban/\(id.rawValue)"] + copied.values.map { "A.kanban/\(id.rawValue)/\($0)" }
|
||||
for path in paths {
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText(path))
|
||||
let created = try #require(document.created.value)
|
||||
let modified = try #require(document.modified.value)
|
||||
#expect(abs(created.timeIntervalSinceNow) < 60)
|
||||
#expect(abs(created.timeIntervalSince(modified)) < 2)
|
||||
#expect(document.modifiedBy == .missing)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
toParent: fixture.url("A.kanban"),
|
||||
order: nil,
|
||||
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)
|
||||
}
|
||||
|
||||
/// A UUID-shaped folder with no `index.md` — interrupted-create residue — is reminted and
|
||||
/// carried like any other folder, and simply not rewritten: the same skip the loader applies.
|
||||
@Test func aNestedFolderWithoutAnIndexIsCopiedAndRemintedButNotRewritten() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
try FileManager.default.createDirectory(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
toParent: fixture.url("A.kanban"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
let names = try fixture.entryNames("A.kanban/\(id.rawValue)").filter(BoardLoader.isUUIDShaped)
|
||||
#expect(names.count == 3)
|
||||
#expect(Set(names).isDisjoint(with: [Ident.card1, Ident.card2, Ident.indexless]))
|
||||
let orphan = try #require(names.first { !fixture.exists("A.kanban/\(id.rawValue)/\($0)/index.md") })
|
||||
#expect(try fixture.entryNames("A.kanban/\(id.rawValue)/\(orphan)") == [])
|
||||
}
|
||||
|
||||
/// The root gets no leniency: it must be rewritten to carry its new `order`, so an
|
||||
/// uneditable one refuses before anything is materialized.
|
||||
@Test func anUneditableRootRefusesTheCopyAndMaterializesNothing() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
|
||||
let before = try fixture.entryNames("A.kanban/\(Ident.lane2)")
|
||||
|
||||
let error = writeFailure {
|
||||
_ = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card3)"),
|
||||
toParent: fixture.url("A.kanban/\(Ident.lane2)"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
}
|
||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||
#expect(error?.operation == "copy item")
|
||||
#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 {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
let unreadable = fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card2)").appendingPathComponent("index.md")
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadable.path)
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: unreadable.path) }
|
||||
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
|
||||
)
|
||||
}
|
||||
guard case .io = error?.reason else {
|
||||
Issue.record("expected .io, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
#expect(try fixture.entryNames("A.kanban") == before)
|
||||
}
|
||||
|
||||
/// A copy always mints, so the import boundary has nothing to do here: landing in a board
|
||||
/// that already holds the source's UUID is not even a special case.
|
||||
@Test func aCopyIntoABoardHoldingTheSameUUIDMintsAnywayWithoutError() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try board(fixture)
|
||||
try fixture.item("B.kanban", Item.board)
|
||||
try fixture.item("B.kanban/\(Ident.lane3)", Item.rich(order: "1024", title: "Inbox"))
|
||||
try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "1024", title: "Twin"))
|
||||
|
||||
let id = try BoardWriter.copyItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"),
|
||||
toParent: fixture.url("B.kanban/\(Ident.lane3)"),
|
||||
order: nil,
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
#expect(id.rawValue != Ident.card1)
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(id.rawValue)")).title
|
||||
== .valid("Card One"))
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)")).title
|
||||
== .valid("Twin"))
|
||||
#expect(try BoardLoader.load(boardRoot: fixture.url("B.kanban")).warnings.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Same-parent move degrades to a reorder
|
||||
|
||||
/// A move whose destination is the item's current parent is the same gesture as any other drop
|
||||
/// — the writer degrades it to the bare `order` rewrite instead of leaking a `FileManager`
|
||||
/// name collision, and the appended rank is computed excluding the item itself, which unlike
|
||||
/// every other move is already sitting among the siblings it would otherwise count.
|
||||
struct BoardWriterSameParentMoveTests {
|
||||
@Test func aSameParentMoveWithNilOrderAppendsAfterTheOtherSiblings() 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: "Lane"))
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
||||
|
||||
let result = try BoardWriter.moveItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"),
|
||||
toParent: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
sourceBoardRoot: fixture.url("A.kanban"),
|
||||
destinationBoardRoot: fixture.url("A.kanban"),
|
||||
order: nil
|
||||
)
|
||||
|
||||
#expect(result.id.rawValue == Ident.card1)
|
||||
#expect(result.reminted.isEmpty)
|
||||
// Appended after Second (2048) and Third (3072), not after its own stale 1024 —
|
||||
// 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:"))
|
||||
}
|
||||
|
||||
@Test func aSameParentMoveWithAnExplicitOrderJustRewritesIt() 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: "Lane"))
|
||||
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "3072", title: "Only"))
|
||||
let laneBefore = try fixture.indexData("A.kanban/\(Ident.lane1)")
|
||||
|
||||
let result = try BoardWriter.moveItem(
|
||||
at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"),
|
||||
toParent: fixture.url("A.kanban/\(Ident.lane1)"),
|
||||
sourceBoardRoot: fixture.url("A.kanban"),
|
||||
destinationBoardRoot: fixture.url("A.kanban"),
|
||||
order: 512
|
||||
)
|
||||
|
||||
#expect(result.id.rawValue == Ident.card1)
|
||||
#expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)").contains("order: 512"))
|
||||
#expect(try fixture.indexData("A.kanban/\(Ident.lane1)") == laneBefore)
|
||||
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").contains(Ident.card1))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user