The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md travels with it, rewritten for lanes, the interior masonry, multi-drag, cross-board sessions, the re-grounding trio, and the committed-overlay hold): - DropSlotMath — resting-layout zones from analytic lane arithmetic and the pure masonry placement (MasonryLayout now lays out through the same MasonryPlacement the drag reads, so geometry cannot drift), span-capped triggers sized to the dragged run's future footprint, hysteresis holds with the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold. - DragAutoScrollMath — the activation bands and velocity ramp, pure. - The drop commits, one performWrite bracket each: moveCards/copyCards within a board (insertion ranks touch only the dragged cards; renumber fallback); receiveCards/receiveLanes/receiveRestoredCards on the destination store for cross-board copy and ⌘-move with the import-boundary remint, lane copies stripping tombstoned cards while moves carry them; restoreByDrag is now positional, writing order only when the drop names a new one. Gestures, sessions, previews, and delegates are the second half. 773 unit tests (87 new since the keyboard grammar). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2231 lines
106 KiB
Swift
2231 lines
106 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
// MARK: - Fixtures
|
|
|
|
// `WriterFixture` lives in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`.
|
|
|
|
private enum Fixture {
|
|
/// Unknown keys in a deliberate order, an own-line comment above and below, an inline
|
|
/// comment, a `modified-by` stamp, and a body — everything one `title` rewrite must leave
|
|
/// exactly as it found it.
|
|
static let rich = """
|
|
---
|
|
# hand-written header
|
|
schema: 1
|
|
title: Original
|
|
order: 1024
|
|
project: lanework # agent overlay
|
|
sphere: work
|
|
labels: [a, b, c]
|
|
created: 2026-07-26T16:41:38Z
|
|
modified: 2026-07-26T19:06:55Z
|
|
modified-by: claude
|
|
# trailing note
|
|
---
|
|
Body text.
|
|
|
|
More body — with *markdown*.
|
|
|
|
"""
|
|
|
|
static let minimal = """
|
|
---
|
|
schema: 1
|
|
order: 1024
|
|
title: Thing
|
|
---
|
|
Body
|
|
|
|
"""
|
|
|
|
/// The whole frontmatter as one flow mapping: reads fine, has no line per key to rewrite.
|
|
static let flowMapping = "---\n{schema: 1, order: 1024}\n---\nbody\n"
|
|
|
|
/// YAML's explicit-key syntax — the key is a sequence, not a name the editor can match.
|
|
static let nonScalarKey = "---\n? [a, b]\n: value\nschema: 1\n---\nbody\n"
|
|
}
|
|
|
|
/// Literal UUID-shaped folder names, so the display-order tie-break is pinned rather than
|
|
/// accidental (`Ranks.isOrderedForDisplay`).
|
|
private enum Child {
|
|
static let a = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
static let b = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
|
static let c = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
|
static let deleted = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
|
static let indexless = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
|
|
}
|
|
|
|
// `Ident` and `Item` live in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`.
|
|
|
|
/// 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
|
|
}
|
|
|
|
// `writeFailure` lives in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`.
|
|
|
|
/// The file's lines minus every line that opens one of `keys` — what a rewrite of exactly those
|
|
/// keys has to leave byte-identical.
|
|
private func lines(of text: String, excludingKeys keys: [String]) -> [String] {
|
|
text.components(separatedBy: "\n")
|
|
.filter { line in !keys.contains { line.hasPrefix("\($0):") } }
|
|
}
|
|
|
|
// MARK: - Preservation
|
|
|
|
struct BoardWriterPreservationTests {
|
|
@Test func aTitleEditTouchesOnlyTheTitleAndTheStamps() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.rich)
|
|
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
|
|
let after = try fixture.indexText("card")
|
|
let stamped = [FrontmatterKeys.title, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
|
|
#expect(lines(of: after, excludingKeys: stamped) == lines(of: Fixture.rich, excludingKeys: stamped))
|
|
#expect(after.contains("title: Renamed\n"))
|
|
|
|
let document = try FrontmatterDocument.parse(after)
|
|
#expect(document.unknownFields.map(\.key) == ["project", "sphere", "labels"])
|
|
#expect(document.keys == [
|
|
"schema", "title", "order", "project", "sphere", "labels", "created", "modified",
|
|
])
|
|
#expect(document.rawValue(for: "labels") == "[a, b, c]")
|
|
#expect(after.hasPrefix("---\n# hand-written header\nschema: 1\n"))
|
|
#expect(after.contains("project: lanework # agent overlay\n"))
|
|
#expect(after.contains("\n# trailing note\n---\n"))
|
|
}
|
|
|
|
@Test func aFrontmatterEditLeavesTheBodyByteIdentical() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.rich)
|
|
let body = Data("Body text.\n\nMore body — with *markdown*.\n".utf8)
|
|
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.order, to: .double(2048))
|
|
}
|
|
|
|
let after = try fixture.indexData("card")
|
|
#expect(after.suffix(body.count) == body)
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText("card")).order == .valid(2048))
|
|
}
|
|
|
|
@Test func aSuccessfulWriteLeavesNoTempFileBehind() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.minimal)
|
|
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
|
|
#expect(try fixture.entryNames("card") == ["index.md"])
|
|
}
|
|
|
|
/// The engine's per-line ending guarantee (01-storage-format.md § Fractal layout ▸ Rules),
|
|
/// verified end to end through the writer: a CRLF file stays CRLF.
|
|
@Test func aCRLFFileKeepsCRLFOnEveryRewrittenLine() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let text = "---\r\nschema: 1\r\norder: 1024\r\ntitle: Thing\r\n"
|
|
+ "modified: 2026-01-01T00:00:00Z\r\nmodified-by: claude\r\n---\r\nbody\r\n"
|
|
let folder = try fixture.item("card", text)
|
|
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
|
|
let after = try fixture.indexText("card")
|
|
#expect(after.contains("title: Renamed\r\n"))
|
|
#expect(after.contains("modified: ") && !after.contains("2026-01-01T00:00:00Z"))
|
|
#expect(!after.contains("modified-by"))
|
|
#expect(after.hasSuffix("---\r\nbody\r\n"))
|
|
// No bare LF survives anywhere once the CRLF pairs are taken out.
|
|
#expect(!after.replacingOccurrences(of: "\r\n", with: "").contains("\n"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Stamps
|
|
|
|
struct BoardWriterStampTests {
|
|
@Test func modifiedIsStampedAndModifiedByIsCleared() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.rich)
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText("card")).modifiedBy == .valid("claude"))
|
|
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
|
|
let after = try fixture.indexText("card")
|
|
#expect(!after.contains("modified-by"))
|
|
|
|
let document = try FrontmatterDocument.parse(after)
|
|
#expect(document.modifiedBy == .missing)
|
|
let modified = try #require(document.modified.value)
|
|
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
|
}
|
|
|
|
/// The stamps run *after* the caller's closure, so they always win — a call site cannot
|
|
/// leave a stale `modified` or a re-added `modified-by` behind.
|
|
@Test func theStampsOutrankWhatTheEditsClosureDid() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.minimal)
|
|
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.modified, to: .date(Date(timeIntervalSince1970: 0)))
|
|
document.set(FrontmatterKeys.modifiedBy, to: .string("claude"))
|
|
}
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText("card"))
|
|
#expect(document.modifiedBy == .missing)
|
|
let modified = try #require(document.modified.value)
|
|
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
|
}
|
|
|
|
/// A file with no `modified` at all gains one — appended before the closing delimiter.
|
|
@Test func aFileWithoutAModifiedKeyGainsOne() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.minimal)
|
|
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText("card"))
|
|
#expect(document.keys == ["schema", "order", "title", "modified"])
|
|
#expect(document.modified.value != nil)
|
|
#expect(document.body == "Body\n")
|
|
}
|
|
}
|
|
|
|
// MARK: - Readable but uneditable
|
|
|
|
/// 01-storage-format.md § Frontmatter, settled: a frontmatter shape the surgical editor cannot
|
|
/// key by spans loads and renders normally, and every app write to it refuses loudly.
|
|
struct BoardWriterUneditableTests {
|
|
@Test func ordinaryDocumentsAreEditable() throws {
|
|
#expect(try FrontmatterDocument.parse(Fixture.rich).uneditableShape == nil)
|
|
#expect(try FrontmatterDocument.parse(Fixture.minimal).uneditableShape == nil)
|
|
#expect(try FrontmatterDocument.parse("---\nflow: {a: 1,\nb: 2}\nlast: x\n---\nbody\n").uneditableShape == nil)
|
|
#expect(FrontmatterDocument(body: "new").uneditableShape == nil)
|
|
}
|
|
|
|
@Test func aWholeFrontmatterFlowMappingReadsButRefusesWrites() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.flowMapping)
|
|
|
|
let document = try FrontmatterDocument.parse(Fixture.flowMapping)
|
|
#expect(document.uneditableShape == .keyWithoutOwnLine)
|
|
#expect(document.schema == .valid(1))
|
|
#expect(document.order == .valid(1024))
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
}
|
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
|
// No title in `Fixture.flowMapping`, so the pre-flight read leaves it nil.
|
|
#expect(error?.operation == .style(title: nil))
|
|
#expect(error?.path.hasSuffix("card/index.md") == true)
|
|
#expect(error?.description.contains("style") == true)
|
|
#expect(try fixture.indexText("card") == Fixture.flowMapping)
|
|
#expect(try fixture.entryNames("card") == ["index.md"])
|
|
}
|
|
|
|
@Test func aNonScalarKeyReadsButRefusesWrites() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.nonScalarKey)
|
|
|
|
#expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).uneditableShape == .nonScalarKey)
|
|
#expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).serialized() == Fixture.nonScalarKey)
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
}
|
|
#expect(error?.reason == .uneditableFrontmatter(.nonScalarKey))
|
|
#expect(try fixture.indexText("card") == Fixture.nonScalarKey)
|
|
}
|
|
|
|
/// A key whose spacing the span matcher cannot key on lands in the same refusal — the
|
|
/// detection is conservative on purpose.
|
|
@Test func aKeyWithSpaceBeforeItsColonIsUneditable() throws {
|
|
#expect(try FrontmatterDocument.parse("---\nschema : 1\n---\nbody\n").uneditableShape == .keyWithoutOwnLine)
|
|
}
|
|
}
|
|
|
|
// MARK: - Failure paths
|
|
|
|
struct BoardWriterFailureTests {
|
|
@Test func aFileThatIsNotUTF8IsALoudErrorAndIsLeftAlone() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let latin1 = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1))
|
|
let folder = try fixture.item("card", bytes: latin1)
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
}
|
|
guard case let .unreadable(message) = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(message.contains("UTF-8"))
|
|
#expect(try fixture.indexData("card") == latin1)
|
|
#expect(try fixture.entryNames("card") == ["index.md"])
|
|
}
|
|
|
|
@Test func aMissingIndexIsALoudError() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = fixture.url("card")
|
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { _ in }
|
|
}
|
|
guard case .unreadable = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(try fixture.entryNames("card") == [])
|
|
}
|
|
|
|
@Test func unparseableFrontmatterIsALoudError() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let text = "---\nschema: 1\n bad: indent\n---\nbody\n"
|
|
let folder = try fixture.item("card", text)
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { _ in }
|
|
}
|
|
guard case .unreadable = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(try fixture.indexText("card") == text)
|
|
}
|
|
|
|
/// The atomicity guarantee under an I/O failure: the temp file cannot be created, so the
|
|
/// destination still holds its previous bytes and nothing is left lying around.
|
|
@Test func aFailedWriteLeavesTheFileAndTheFolderExactlyAsTheyWere() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item("card", Fixture.rich)
|
|
let before = try fixture.indexData("card")
|
|
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: folder.path)
|
|
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: folder.path) }
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
|
}
|
|
}
|
|
guard case .io = error?.reason else {
|
|
Issue.record("expected .io, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
// `updateIndex` read `Fixture.rich` (title: Original) successfully before the write
|
|
// itself failed — title enrichment engages even though the failure is `.io`, not a
|
|
// read/uneditable refusal.
|
|
#expect(error?.operation == .style(title: "Original"))
|
|
#expect(error?.path.hasSuffix("card/index.md") == true)
|
|
#expect(try fixture.indexData("card") == before)
|
|
#expect(try fixture.entryNames("card") == ["index.md"])
|
|
}
|
|
}
|
|
|
|
// MARK: - Renumber
|
|
|
|
/// 01-storage-format.md § Ordering: the renumber fallback — the sole exception to
|
|
/// "a reorder rewrites only the moved item's index.md".
|
|
struct BoardWriterRenumberTests {
|
|
/// Crowded orders whose display sequence disagrees with folder-name order, so the assignment
|
|
/// is provably by `order` first and name only as tie-break.
|
|
private func crowdedLane(_ fixture: WriterFixture) throws -> URL {
|
|
try fixture.item("lane/\(Child.a)", child(order: "1.0000003", title: "A"))
|
|
try fixture.item("lane/\(Child.b)", child(order: "1.0000001", title: "B"))
|
|
try fixture.item("lane/\(Child.c)", child(order: "1.0000002", title: "C"))
|
|
try fixture.item(
|
|
"lane/\(Child.deleted)",
|
|
"---\nschema: 1\norder: 0.5\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\nmodified-by: claude\n---\nbody\n"
|
|
)
|
|
// Skipped for the same reasons BoardLoader skips them.
|
|
try fixture.item("lane/notes", "---\nschema: 1\norder: 1\ntitle: Stray\n---\nbody\n")
|
|
try FileManager.default.createDirectory(
|
|
at: fixture.url("lane/\(Child.indexless)"),
|
|
withIntermediateDirectories: true
|
|
)
|
|
return fixture.url("lane")
|
|
}
|
|
|
|
private func child(order: String, title: String) -> String {
|
|
"""
|
|
---
|
|
schema: 1
|
|
order: \(order)
|
|
title: \(title)
|
|
project: lanework # agent overlay
|
|
modified-by: claude
|
|
---
|
|
\(title) body
|
|
|
|
"""
|
|
}
|
|
|
|
@Test func visibleChildrenAreRenumberedInDisplayOrder() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let lane = try crowdedLane(fixture)
|
|
|
|
try BoardWriter.renumberVisibleChildren(of: lane)
|
|
|
|
let orders = try [Child.b, Child.c, Child.a].map {
|
|
try FrontmatterDocument.parse(fixture.indexText("lane/\($0)")).order
|
|
}
|
|
#expect(orders == [.valid(1024), .valid(2048), .valid(3072)])
|
|
#expect(try fixture.indexText("lane/\(Child.b)").contains("order: 1024\n"))
|
|
}
|
|
|
|
@Test func eachRewrittenChildIsStampedAndKeepsItsUnknownKeys() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let lane = try crowdedLane(fixture)
|
|
|
|
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.unknownFields.map(\.key) == ["project"])
|
|
#expect(text.contains("project: lanework # agent overlay\n"))
|
|
#expect(document.body.hasSuffix(" body\n"))
|
|
#expect(try fixture.entryNames("lane/\(name)") == ["index.md"])
|
|
}
|
|
}
|
|
|
|
@Test func tombstonedAndStrayChildrenAreUntouched() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let lane = try crowdedLane(fixture)
|
|
let tombstone = try fixture.indexData("lane/\(Child.deleted)")
|
|
let stray = try fixture.indexData("lane/notes")
|
|
|
|
try BoardWriter.renumberVisibleChildren(of: lane)
|
|
|
|
#expect(try fixture.indexData("lane/\(Child.deleted)") == tombstone)
|
|
#expect(try fixture.indexData("lane/notes") == stray)
|
|
}
|
|
|
|
/// A renumber runs over loaded, valid children: one broken sibling fails the whole
|
|
/// operation, and it fails before anything has been rewritten.
|
|
@Test func aChildWithAMalformedOrderFailsTheWholeRenumber() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A"))
|
|
try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: banana\ntitle: B\n---\nbody\n")
|
|
let untouched = try fixture.indexData("lane/\(Child.a)")
|
|
|
|
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) }
|
|
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
|
|
#expect(error?.path.contains(Child.b) == true)
|
|
#expect(error?.operation == .renumberChildren)
|
|
#expect(try fixture.indexData("lane/\(Child.a)") == untouched)
|
|
}
|
|
|
|
@Test func aChildWithNoOrderFailsTheWholeRenumber() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A"))
|
|
try fixture.item("lane/\(Child.b)", "---\nschema: 1\ntitle: B\n---\nbody\n")
|
|
let untouched = try fixture.indexData("lane/\(Child.a)")
|
|
|
|
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) }
|
|
#expect(error?.reason == .unreadable(message: "missing required 'order' field"))
|
|
#expect(error?.path.contains(Child.b) == true)
|
|
#expect(try fixture.indexData("lane/\(Child.a)") == untouched)
|
|
}
|
|
|
|
@Test func aChildWithUneditableFrontmatterFailsTheWholeRenumber() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A"))
|
|
try fixture.item("lane/\(Child.b)", Fixture.flowMapping)
|
|
let untouched = try fixture.indexData("lane/\(Child.a)")
|
|
|
|
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) }
|
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
|
#expect(try fixture.indexData("lane/\(Child.a)") == untouched)
|
|
}
|
|
|
|
@Test func renumberingAnEmptyParentIsANoOp() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try FileManager.default.createDirectory(at: fixture.url("lane"), withIntermediateDirectories: true)
|
|
try BoardWriter.renumberVisibleChildren(of: fixture.url("lane"))
|
|
#expect(try fixture.entryNames("lane") == [])
|
|
}
|
|
}
|
|
|
|
// MARK: - Loader integration
|
|
|
|
struct BoardWriterLoaderIntegrationTests {
|
|
/// The temp files are dot-prefixed precisely so `BoardLoader`'s `.skipsHiddenFiles` never
|
|
/// sees a crashed write's residue — not as a stray warning, and not as a candidate.
|
|
@Test func leftoverTempFilesAreInvisibleToTheLoader() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
|
|
try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: Lane\n---\n")
|
|
|
|
for folder in ["", Child.a] {
|
|
let residue = fixture.url(folder).appendingPathComponent(".index.md.lanework-\(UUID().uuidString)")
|
|
try Data("half-written\n".utf8).write(to: residue)
|
|
}
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
|
#expect(result.warnings.isEmpty)
|
|
#expect(result.model.lanes.map(\.id.rawValue) == [Child.a])
|
|
}
|
|
|
|
/// A board written by the writer reloads to the same values it was given.
|
|
@Test func aWrittenBoardReloadsCleanly() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
|
|
let lane = try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: Lane\n---\n")
|
|
try fixture.item("\(Child.a)/\(Child.b)", "---\nschema: 1\norder: 1024\ntitle: Card\n---\nbody\n")
|
|
|
|
try BoardWriter.updateIndex(inItemFolder: lane, operation: .style(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Doing"))
|
|
}
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
|
#expect(result.warnings.isEmpty)
|
|
#expect(result.model.lanes.first?.title == .valid("Doing"))
|
|
#expect(result.model.lanes.first?.modifiedBy == .missing)
|
|
#expect(result.model.lanes.first?.modified.value != nil)
|
|
#expect(result.model.lanes.first?.cards.first?.title == .valid("Card"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Create board
|
|
|
|
/// `BoardWriter.createBoard`: the folder (if missing) plus a freshly minted `index.md` — never
|
|
/// a rewrite, so these tests check what a brand-new file looks like rather than what a rewrite
|
|
/// preserved.
|
|
struct BoardWriterCreateBoardTests {
|
|
@Test func createsAFolderAndAValidIndexTheLoaderOpens() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = fixture.url("MyBoard.kanban")
|
|
|
|
try BoardWriter.createBoard(at: root, title: "My Board")
|
|
|
|
let result = try BoardLoader.load(boardRoot: root)
|
|
#expect(result.warnings.isEmpty)
|
|
#expect(result.model.schema == 1)
|
|
#expect(result.model.title == .valid("My Board"))
|
|
#expect(result.model.modifiedBy == .missing)
|
|
|
|
let created = try #require(result.model.created.value)
|
|
let modified = try #require(result.model.modified.value)
|
|
#expect(abs(created.timeIntervalSinceNow) < 60)
|
|
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
|
}
|
|
|
|
@Test func keyOrderIsSchemaTitleCreatedModified() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = fixture.url("MyBoard.kanban")
|
|
|
|
try BoardWriter.createBoard(at: root, title: "My Board")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText("MyBoard.kanban"))
|
|
#expect(document.keys == ["schema", "title", "created", "modified"])
|
|
}
|
|
|
|
@Test func aNilTitleWritesNoTitleKeyAndTheLoaderReadsItMissing() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = fixture.url("Untitled.kanban")
|
|
|
|
try BoardWriter.createBoard(at: root, title: nil)
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText("Untitled.kanban"))
|
|
#expect(document.keys == ["schema", "created", "modified"])
|
|
#expect(!document.contains(FrontmatterKeys.title))
|
|
|
|
let result = try BoardLoader.load(boardRoot: root)
|
|
#expect(result.model.title == .missing)
|
|
}
|
|
|
|
@Test func refusesToClobberAnExistingBoard() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let existing = "---\nschema: 1\ntitle: Existing\n---\nbody\n"
|
|
let root = try fixture.item("Existing.kanban", existing)
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.createBoard(at: root, title: "New")
|
|
}
|
|
guard case .io = error?.reason else {
|
|
Issue.record("expected .io, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(error?.path.hasSuffix("Existing.kanban/index.md") == true)
|
|
#expect(try fixture.indexText("Existing.kanban") == existing)
|
|
}
|
|
|
|
@Test func anExtensionLessBoardFolderIsCreatedAndOpensSuccessfully() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = fixture.url("PlainBoard")
|
|
|
|
try BoardWriter.createBoard(at: root, title: "Plain")
|
|
|
|
let result = try BoardLoader.load(boardRoot: root)
|
|
#expect(result.warnings.isEmpty)
|
|
#expect(result.model.title == .valid("Plain"))
|
|
}
|
|
|
|
@Test func createdEqualsModifiedAtCreationTime() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = fixture.url("Board.kanban")
|
|
|
|
try BoardWriter.createBoard(at: root, title: "Board")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText("Board.kanban"))
|
|
#expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified))
|
|
}
|
|
}
|
|
|
|
// MARK: - Create lane / card
|
|
|
|
/// `BoardWriter.createLane`/`createCard`: minting a fresh identity and placing it after the
|
|
/// current visible siblings (01-storage-format.md § Ordering). `fixture.root` doubles as the
|
|
/// board root for lane creation — `createChild` never reads the parent's own `index.md`, only
|
|
/// its children, so a bare directory is as good a board root as a fully-formed one for these
|
|
/// tests.
|
|
struct BoardWriterCreateChildTests {
|
|
@Test func mintsLowercaseUUIDShapedNamesDistinctAcrossCalls() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
let first = try BoardWriter.createLane(inBoard: fixture.root, title: "Todo")
|
|
let second = try BoardWriter.createLane(inBoard: fixture.root, title: "Doing")
|
|
|
|
for id in [first, second] {
|
|
#expect(BoardLoader.isUUIDShaped(id.rawValue))
|
|
#expect(id.rawValue == id.rawValue.lowercased())
|
|
}
|
|
#expect(first != second)
|
|
}
|
|
|
|
@Test func laneKeyOrderIsSchemaTitleOrderCreatedModified() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue))
|
|
#expect(document.keys == ["schema", "title", "order", "created", "modified"])
|
|
#expect(document.schema == .valid(1))
|
|
#expect(document.modifiedBy == .missing)
|
|
}
|
|
|
|
@Test func anEmptyParentsFirstChildLandsAt1024() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Only")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue))
|
|
#expect(document.order == .valid(1024))
|
|
}
|
|
|
|
@Test func appendsAfterVisibleSiblingsAtMaxPlus1024() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
|
|
try fixture.item(Child.b, "---\nschema: 1\norder: 2048\ntitle: B\n---\nbody\n")
|
|
|
|
let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue))
|
|
#expect(document.order == .valid(3072))
|
|
}
|
|
|
|
/// The highest-order sibling is tombstoned — tombstones are inert to ordering, so it must
|
|
/// not factor into the append target at all (not `9999 + 1024`, not anything derived from it).
|
|
@Test func aTombstonedHighestOrderSiblingIsExcludedFromTheAppendTarget() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
|
|
try fixture.item(
|
|
Child.deleted,
|
|
"---\nschema: 1\norder: 9999\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
|
)
|
|
|
|
let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue))
|
|
#expect(document.order == .valid(2048))
|
|
}
|
|
|
|
/// A UUID-shaped sibling folder with no `index.md` — an interrupted create — is skipped
|
|
/// during the order scan, not fatal; the new item still lands correctly, and the board as a
|
|
/// whole still loads, surfacing only the loader's ordinary `.missingIndex` warning.
|
|
@Test func aSiblingFolderWithoutIndexIsSkippedNotFatal() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
|
|
try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
|
|
try FileManager.default.createDirectory(at: fixture.url(Child.indexless), withIntermediateDirectories: true)
|
|
|
|
let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue))
|
|
#expect(document.order == .valid(2048))
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
|
#expect(result.warnings == [.missingIndex(path: Child.indexless)])
|
|
#expect(Set(result.model.lanes.map(\.id.rawValue)) == Set([Child.a, newID.rawValue]))
|
|
}
|
|
|
|
@Test func aSiblingWithAMalformedOrderFailsTheCreateNamingTheSibling() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(Child.a, "---\nschema: 1\norder: banana\ntitle: A\n---\nbody\n")
|
|
let before = try fixture.entryNames("")
|
|
|
|
let error = writeFailure {
|
|
_ = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
|
|
}
|
|
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
|
|
#expect(error?.path.contains(Child.a) == true)
|
|
#expect(error?.operation == .createLane)
|
|
// Nothing was minted: the scan fails before the new folder is ever created.
|
|
#expect(try fixture.entryNames("") == before)
|
|
}
|
|
|
|
@Test func aMissingParentFolderIsALoudUnreadableError() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let missingParent = fixture.url("does-not-exist")
|
|
|
|
let error = writeFailure {
|
|
_ = try BoardWriter.createLane(inBoard: missingParent, title: "New")
|
|
}
|
|
guard case .unreadable = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
}
|
|
|
|
@Test func aParentThatIsAFileIsALoudUnreadableError() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let filePath = fixture.root.appendingPathComponent("afile")
|
|
try Data("x".utf8).write(to: filePath)
|
|
|
|
let error = writeFailure {
|
|
_ = try BoardWriter.createLane(inBoard: filePath, title: "New")
|
|
}
|
|
guard case .unreadable = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
}
|
|
|
|
@Test func createdEqualsModifiedAtCreationTime() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue))
|
|
#expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified))
|
|
}
|
|
|
|
/// `createCard` is `createLane` one level down — a lane folder (need not have its own
|
|
/// `index.md` for this to work; `createChild` never reads the parent's own file) is just as
|
|
/// good a parent as a board root.
|
|
@Test func createCardMintsUnderALaneFolderWithTheSameRules() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane")
|
|
let laneFolder = fixture.url(laneID.rawValue)
|
|
|
|
let first = try BoardWriter.createCard(inLane: laneFolder, title: "First")
|
|
let second = try BoardWriter.createCard(inLane: laneFolder, title: "Second")
|
|
|
|
#expect(BoardLoader.isUUIDShaped(first.rawValue))
|
|
#expect(first != second)
|
|
let firstDocument = try FrontmatterDocument.parse(fixture.indexText("\(laneID.rawValue)/\(first.rawValue)"))
|
|
let secondDocument = try FrontmatterDocument.parse(fixture.indexText("\(laneID.rawValue)/\(second.rawValue)"))
|
|
#expect(firstDocument.order == .valid(1024))
|
|
#expect(secondDocument.order == .valid(2048))
|
|
}
|
|
}
|
|
|
|
// MARK: - Create round-trip
|
|
|
|
struct BoardWriterCreateIntegrationTests {
|
|
/// `createBoard` → `createLane` → `createCard` → `BoardLoader.load`: the whole structure
|
|
/// shows up, in append order, exactly as created.
|
|
@Test func createBoardCreateLaneCreateCardRoundTripsThroughTheLoader() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = fixture.url("Trip.kanban")
|
|
|
|
try BoardWriter.createBoard(at: root, title: "Trip")
|
|
let lane1 = try BoardWriter.createLane(inBoard: root, title: "Todo")
|
|
let lane2 = try BoardWriter.createLane(inBoard: root, title: "Doing")
|
|
let lane1Folder = root.appendingPathComponent(lane1.rawValue, isDirectory: true)
|
|
let card1 = try BoardWriter.createCard(inLane: lane1Folder, title: "First")
|
|
let card2 = try BoardWriter.createCard(inLane: lane1Folder, title: "Second")
|
|
|
|
let result = try BoardLoader.load(boardRoot: root)
|
|
#expect(result.warnings.isEmpty)
|
|
#expect(result.model.title == .valid("Trip"))
|
|
#expect(result.model.lanes.map(\.id.rawValue) == [lane1.rawValue, lane2.rawValue])
|
|
#expect(result.model.lanes[0].title == .valid("Todo"))
|
|
#expect(result.model.lanes[1].title == .valid("Doing"))
|
|
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card1.rawValue, card2.rawValue])
|
|
#expect(result.model.lanes[0].cards[0].title == .valid("First"))
|
|
#expect(result.model.lanes[0].cards[1].title == .valid("Second"))
|
|
#expect(result.model.lanes[1].cards.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Editability scope: reads vs rewrites
|
|
|
|
/// The readable-but-uneditable pre-flight belongs only to operations that will *rewrite*
|
|
/// siblings. A flow-mapping sibling loads and renders normally (01-storage-format.md §
|
|
/// Frontmatter), so it must not block creating a new item beside it — but renumber, which
|
|
/// would have to rewrite that very file, refuses up front, before anything is written.
|
|
struct BoardWriterEditabilityScopeTests {
|
|
private static let flowSibling = "---\n{schema: 1, order: 5000}\n---\n"
|
|
|
|
@Test func createSucceedsBesideAnUneditableSibling() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", "---\nschema: 1\n---\n")
|
|
try fixture.item(Child.a, Self.flowSibling)
|
|
|
|
let id = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
|
|
let text = try fixture.indexText(id.rawValue)
|
|
#expect(text.contains("order: 6024"))
|
|
}
|
|
|
|
@Test func renumberRefusesOnAnUneditableSibling() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", "---\nschema: 1\n---\n")
|
|
try fixture.item(Child.a, Self.flowSibling)
|
|
try fixture.item(Child.b, "---\nschema: 1\norder: 1024\n---\n")
|
|
let before = try fixture.indexData(Child.b)
|
|
|
|
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.root) }
|
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
|
#expect(error?.path.hasSuffix("\(Child.a)/index.md") == true)
|
|
#expect(try fixture.indexData(Child.b) == before)
|
|
#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)
|
|
}
|
|
|
|
/// **Identity comparison is UUID-value equality, never string equality** (01-storage-format.md
|
|
/// § Fractal layout ▸ Rules, settled): the destination board already holds the arriving UUID
|
|
/// spelled in *uppercase* — an agent's `uuidgen` card — so the two are one identity and the
|
|
/// import boundary must remint. A verbatim string set would sail straight past this and leave
|
|
/// a duplicate UUID in the board.
|
|
@Test func aCollisionSpelledInADifferentCaseIsStillOneIdentityAndRemints() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try boardA(fixture)
|
|
try boardB(fixture)
|
|
|
|
let arriving = "abcdef01-2345-6789-abcd-ef0123456789"
|
|
let twin = arriving.uppercased() // The same UUID, as `uuidgen` would have printed it.
|
|
try fixture.item("A.kanban/\(Ident.lane1)/\(arriving)", Item.rich(order: "3072", title: "Card One"))
|
|
try fixture.item("B.kanban/\(Ident.lane3)/\(twin)", Item.rich(order: "2048", title: "Stale Twin"))
|
|
let twinBefore = try fixture.indexData("B.kanban/\(Ident.lane3)/\(twin)")
|
|
|
|
let result = try move(
|
|
fixture, "A.kanban/\(Ident.lane1)/\(arriving)",
|
|
to: "B.kanban/\(Ident.lane4)", into: "B.kanban"
|
|
)
|
|
|
|
let minted = result.id.rawValue
|
|
#expect(BoardLoader.isUUIDShaped(minted))
|
|
#expect(ItemID(rawValue: minted) != ItemID(rawValue: arriving))
|
|
#expect(ItemID(rawValue: minted) != ItemID(rawValue: twin))
|
|
#expect(result.reminted == [MoveResult.Remint(from: ItemID(rawValue: arriving), to: ItemID(rawValue: minted))])
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane4)/\(minted)")).title
|
|
== .valid("Card One"))
|
|
|
|
// The resident twin is untouched, and the source left as for any move.
|
|
#expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(twin)") == twinBefore)
|
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(arriving)"))
|
|
}
|
|
|
|
/// The same rule one level down: a lane arrives carrying a card whose UUID the destination
|
|
/// board already holds under a different case-spelling — that card, and only that card, is
|
|
/// reminted.
|
|
@Test func aLaneMoveRemintsAChildCollidingOnlyByCaseSpelling() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try boardA(fixture)
|
|
try boardB(fixture)
|
|
|
|
let arriving = "abcdef01-2345-6789-abcd-ef0123456789"
|
|
let twin = arriving.uppercased()
|
|
try fixture.item("A.kanban/\(Ident.lane1)/\(arriving)", Item.rich(order: "3072", title: "Colliding"))
|
|
try fixture.item("B.kanban/\(Ident.lane3)/\(twin)", Item.rich(order: "2048", title: "B's Own"))
|
|
|
|
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) == [arriving])
|
|
let minted = try #require(result.reminted.first?.to.rawValue)
|
|
#expect(fixture.exists("B.kanban/\(Ident.lane1)/\(minted)"))
|
|
#expect(!fixture.exists("B.kanban/\(Ident.lane1)/\(arriving)"))
|
|
// The card that collided with nothing kept its identity.
|
|
#expect(fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
|
}
|
|
|
|
/// 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))
|
|
// The pre-flight read the source's document (`Item.uneditable`, title: Odd) before the
|
|
// shape refusal, so the title survives into the thrown error.
|
|
#expect(error?.operation == .move(title: "Odd"))
|
|
#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"))
|
|
}
|
|
|
|
/// A symlink surviving a copy as a symlink — never resolved, never followed to its target's
|
|
/// bytes — is `FileManager.copyItem`'s own default behavior (01-storage-format.md § Fractal
|
|
/// layout ▸ Rules, "Symlinks are never traversed": "Copy flows copy the link itself, never its
|
|
/// target"). Nothing in `copyItem` arranges this on purpose — it falls out of leaning on
|
|
/// `FileManager.copyItem` for the whole subtree rather than reading files one by one — so this
|
|
/// pins the behavior against a future change (a different copy mechanism, a Foundation
|
|
/// update) silently starting to dereference links instead.
|
|
@Test func copyItemPreservesASymlinkVerbatimInsideACopiedSubtree() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try board(fixture)
|
|
let cardPath = "A.kanban/\(Ident.lane1)/\(Ident.card1)"
|
|
try fixture.file("\(cardPath)/attachments/real.png", Data([0x89, 0x50, 0x4E, 0x47]))
|
|
try FileManager.default.createSymbolicLink(
|
|
atPath: fixture.url("\(cardPath)/attachments/linked.png").path,
|
|
withDestinationPath: "real.png"
|
|
)
|
|
|
|
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"])
|
|
let copiedLink = fixture.url("A.kanban/\(id.rawValue)/\(card)/attachments/linked.png")
|
|
|
|
let values = try copiedLink.resourceValues(forKeys: [.isSymbolicLinkKey])
|
|
#expect(values.isSymbolicLink == true)
|
|
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: copiedLink.path) == "real.png")
|
|
// The source link is untouched too — a copy never touches what it reads from.
|
|
#expect(
|
|
try FileManager.default.destinationOfSymbolicLink(
|
|
atPath: fixture.url("\(cardPath)/attachments/linked.png").path
|
|
) == "real.png"
|
|
)
|
|
}
|
|
|
|
/// 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))
|
|
// Same enrichment as the move pre-flight: the root's document (title: Odd) was read
|
|
// before the uneditable-shape refusal fired.
|
|
#expect(error?.operation == .copy(title: "Odd"))
|
|
#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: - Stripping a copied lane's tombstones
|
|
|
|
/// `BoardWriter.stripTombstonedChildren` — the tail of a lane copy (04-interactions.md ▸ Drag and
|
|
/// drop: "A lane copy **strips tombstoned cards**"). `copyItem` copies the tree verbatim by
|
|
/// design, so the strip is the line after it rather than a filter inside it.
|
|
struct BoardWriterStripTombstonesTests {
|
|
|
|
/// A tombstoned card, as an agent or a delete leaves it.
|
|
private static func tombstone(order: String, title: String) -> String {
|
|
"---\nschema: 1\ntitle: \(title)\norder: \(order)\ndeleted: 2026-03-03T09:00:00Z\n---\n\(title) body.\n"
|
|
}
|
|
|
|
@Test func onlyTheTombstonedChildrenAreRemoved() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("A.kanban", Item.board)
|
|
let lane = 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: "Live"))
|
|
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)",
|
|
Self.tombstone(order: "2048", title: "Trashed"))
|
|
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Also live"))
|
|
let live = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
|
|
|
|
#expect(removed == [ItemID(rawValue: Ident.card2)])
|
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card2)"))
|
|
// Removed, never tombstoned, and the survivors are not rewritten on the way past.
|
|
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
|
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card3)"))
|
|
#expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") == live)
|
|
#expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty)
|
|
}
|
|
|
|
@Test func aWholeTombstonedFolderGoesWithItsAttachments() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("A.kanban", Item.board)
|
|
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)",
|
|
Self.tombstone(order: "1024", title: "Trashed"))
|
|
try fixture.file("A.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x89, 0x50]))
|
|
|
|
_ = try BoardWriter.stripTombstonedChildren(of: lane)
|
|
|
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
|
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
|
|
}
|
|
|
|
@Test func nonUUIDStraysAndUnreadableChildrenAreLeftAlone() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("A.kanban", Item.board)
|
|
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
|
// A stray is not a level at all; a UUID-shaped folder with no `index.md` cannot be asked
|
|
// the liveness question, and the conservative direction is to keep it.
|
|
try fixture.file("A.kanban/\(Ident.lane1)/notes/scratch.txt", Data("hand-written\n".utf8))
|
|
try FileManager.default.createDirectory(at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"),
|
|
withIntermediateDirectories: true)
|
|
|
|
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
|
|
|
|
#expect(removed.isEmpty)
|
|
#expect(fixture.exists("A.kanban/\(Ident.lane1)/notes/scratch.txt"))
|
|
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.indexless)"))
|
|
}
|
|
|
|
@Test func aLaneWithNothingTombstonedIsUntouched() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("A.kanban", Item.board)
|
|
let lane = 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: "Live"))
|
|
|
|
#expect(try BoardWriter.stripTombstonedChildren(of: lane).isEmpty)
|
|
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").sorted() == [Ident.card1, "index.md"].sorted())
|
|
}
|
|
|
|
@Test func aMissingFolderIsALoudErrorNamingTheCopy() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
let error = writeFailure { _ = try BoardWriter.stripTombstonedChildren(of: fixture.url("A.kanban/\(Ident.lane1)")) }
|
|
// The user pressed nothing called "delete": a failure here must say the copy failed.
|
|
#expect(error?.operation == .copy(title: nil))
|
|
}
|
|
}
|
|
|
|
// MARK: - Delete / Restore
|
|
|
|
/// `BoardWriter.deleteItem`/`restoreItem` — the tombstone half of 01-storage-format.md §
|
|
/// Deletion: `deleted: <now>` written into the item's own `index.md` in place, and Put Back
|
|
/// (`remove(deleted)`) undoing exactly that. The folder never moves; hiding a tombstoned
|
|
/// subtree is the renderer's ancestor walk, not anything either call does — a deleted lane's
|
|
/// cards are never touched.
|
|
struct BoardWriterDeleteRestoreTests {
|
|
@Test func deletingALaneWritesATombstoneInPlaceAndPreservesEverythingElse() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("A.kanban", Item.board)
|
|
let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
|
let before = try fixture.indexText("A.kanban/\(Ident.lane1)")
|
|
|
|
try BoardWriter.deleteItem(at: laneFolder)
|
|
|
|
// Same path, same name — a tombstone never moves or renames the folder.
|
|
#expect(fixture.exists("A.kanban/\(Ident.lane1)"))
|
|
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
|
|
|
|
let after = try fixture.indexText("A.kanban/\(Ident.lane1)")
|
|
let stamped = [FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.deleted]
|
|
#expect(lines(of: after, excludingKeys: stamped) == lines(of: before, excludingKeys: stamped))
|
|
|
|
let document = try FrontmatterDocument.parse(after)
|
|
#expect(document.unknownFields.map(\.key) == ["project", "labels"])
|
|
#expect(document.body.hasSuffix("body — with *markdown*.\n"))
|
|
#expect(document.modifiedBy == .missing)
|
|
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
|
|
let deleted = try #require(document.deleted.value)
|
|
#expect(abs(deleted.timeIntervalSinceNow) < 60)
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
|
|
#expect(result.model.lanes.first?.isDeleted == true)
|
|
}
|
|
|
|
/// Hiding beneath is the renderer's walk, not a stored flag: deleting a lane writes only
|
|
/// the lane's own file.
|
|
@Test func deletingALaneLeavesItsNestedCardUntouched() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("A.kanban", Item.board)
|
|
let laneFolder = 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"))
|
|
let cardBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
try BoardWriter.deleteItem(at: laneFolder)
|
|
|
|
#expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == cardBefore)
|
|
}
|
|
|
|
@Test func loaderRoundTripDeletingACardShowsItDeletedStillInTheSnapshotAtItsRecordedOrder() 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"))
|
|
let cardFolder = 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 BoardWriter.deleteItem(at: cardFolder)
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
|
|
#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 == true)
|
|
#expect(cards[0].order == 1024)
|
|
#expect(cards[1].isDeleted == false)
|
|
}
|
|
|
|
/// After restore the file carries no residue of `deleted` at all, and the item reappears
|
|
/// among its current siblings at the `order` it had all along.
|
|
@Test func deleteThenRestoreLeavesNoResidueAndReappearsAtItsRecordedOrder() 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"))
|
|
let cardFolder = try fixture.item(
|
|
"A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1536", title: "Card One")
|
|
)
|
|
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
|
|
|
|
try BoardWriter.deleteItem(at: cardFolder)
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
|
.deleted.value != nil)
|
|
|
|
try BoardWriter.restoreItem(at: cardFolder)
|
|
|
|
let after = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
|
#expect(!after.contains("deleted"))
|
|
let document = try FrontmatterDocument.parse(after)
|
|
#expect(document.deleted == .missing)
|
|
#expect(document.order == .valid(1536))
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
|
|
let cards = try #require(result.model.lanes.first?.cards)
|
|
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
|
|
#expect(cards[0].isDeleted == false)
|
|
}
|
|
|
|
/// `remove` takes every occurrence, so a hand-duplicated `deleted` line cannot resurrect
|
|
/// the tombstone the instant the winning one is gone.
|
|
@Test func restoreRemovesAHandDuplicatedDeletedKeyEntirely() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let text = """
|
|
---
|
|
schema: 1
|
|
order: 1024
|
|
title: Twice Gone
|
|
deleted: 2026-01-01T00:00:00Z
|
|
deleted: 2026-06-01T00:00:00Z
|
|
---
|
|
body
|
|
|
|
"""
|
|
let folder = try fixture.item(Ident.lane1, text)
|
|
|
|
try BoardWriter.restoreItem(at: folder)
|
|
|
|
let after = try fixture.indexText(Ident.lane1)
|
|
#expect(after.components(separatedBy: "\n").filter { $0.hasPrefix("deleted:") }.isEmpty)
|
|
#expect(try FrontmatterDocument.parse(after).deleted == .missing)
|
|
}
|
|
|
|
/// Tombstones are inert to ordering: a sibling deleted via `deleteItem` must not factor into
|
|
/// a subsequent create's append target.
|
|
@Test func aTombstonedSiblingIsExcludedFromTheAppendRankAfterDeleteItem() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
|
|
let highOrder = try fixture.item(Child.b, "---\nschema: 1\norder: 9999\ntitle: B\n---\nbody\n")
|
|
|
|
try BoardWriter.deleteItem(at: highOrder)
|
|
let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
|
|
|
|
let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue))
|
|
#expect(document.order == .valid(2048))
|
|
}
|
|
|
|
@Test func renumberLeavesATombstonedSiblingByteIdentical() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("lane/\(Child.a)", "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
|
|
let toDelete = try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: 3000\ntitle: B\n---\nbody\n")
|
|
|
|
try BoardWriter.deleteItem(at: toDelete)
|
|
let tombstoneAfterDelete = try fixture.indexData("lane/\(Child.b)")
|
|
|
|
try BoardWriter.renumberVisibleChildren(of: fixture.url("lane"))
|
|
|
|
#expect(try fixture.indexData("lane/\(Child.b)") == tombstoneAfterDelete)
|
|
}
|
|
|
|
/// Board-root deletion is structurally unreachable at the writer level: a board root's
|
|
/// folder name is never UUID-shaped.
|
|
@Test func deleteRefusesANonUUIDShapedFolder() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = try fixture.item("A.kanban", Item.board)
|
|
|
|
let error = writeFailure { try BoardWriter.deleteItem(at: root) }
|
|
guard case let .unreadable(message) = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(message.contains("UUID-shaped"))
|
|
#expect(try fixture.indexText("A.kanban") == Item.board)
|
|
}
|
|
|
|
@Test func restoreRefusesANonUUIDShapedFolder() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = try fixture.item("A.kanban", Item.board)
|
|
|
|
let error = writeFailure { try BoardWriter.restoreItem(at: root) }
|
|
guard case let .unreadable(message) = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(message.contains("UUID-shaped"))
|
|
#expect(try fixture.indexText("A.kanban") == Item.board)
|
|
}
|
|
|
|
/// Comes free via `updateIndex`'s pre-flight: a readable-but-uneditable shape refuses every
|
|
/// app-mediated write, delete included.
|
|
@Test func deleteOnAnUneditableItemRefuses() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item(Ident.lane1, Fixture.flowMapping)
|
|
|
|
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
|
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
|
#expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping)
|
|
}
|
|
|
|
// MARK: Title enrichment (02-architecture.md § Write-failure surfacing)
|
|
|
|
/// `updateIndex`'s pre-flight read succeeds — the shape is readable, only uneditable — so by
|
|
/// the time the refusal fires, `WriteOperation.withTitle` has already run: the title survives
|
|
/// into the thrown error. `Fixture.flowMapping` above has no `title` key at all, which is why
|
|
/// this test reaches for `Item.uneditable` instead — the fixture that actually carries one.
|
|
@Test func deleteOnAnUneditableItemWithAKnownTitleCarriesItInTheOperation() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item(Ident.lane1, Item.uneditable)
|
|
|
|
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
|
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
|
#expect(error?.operation == .delete(title: "Odd"))
|
|
}
|
|
|
|
/// The negative case: a file that cannot even be read (invalid UTF-8) never gets far enough
|
|
/// for `readDocument` to hand back a document, so there is no title to learn — the operation
|
|
/// stays exactly as its call site constructed it, title `nil`.
|
|
@Test func deleteOnAnUnreadableIndexLeavesTheOperationsTitleNil() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let garbage = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1))
|
|
let folder = try fixture.item(Ident.lane1, bytes: garbage)
|
|
|
|
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
|
|
guard case .unreadable = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(error?.operation == .delete(title: nil))
|
|
}
|
|
}
|
|
|
|
// MARK: - Purge
|
|
|
|
/// `BoardWriter.purgeItem`: physical removal — Delete Immediately / Empty Trash
|
|
/// (01-storage-format.md § Deletion) — irreversible, and distinct from tombstoning. Does not
|
|
/// require the item to be tombstoned first: Delete Immediately skips that stage by design.
|
|
struct BoardWriterPurgeTests {
|
|
@Test func purgeRemovesTheFolderTreeIncludingNestedContentFromDisk() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("A.kanban", Item.board)
|
|
let laneFolder = 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 BoardWriter.purgeItem(at: laneFolder)
|
|
|
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)"))
|
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
|
}
|
|
|
|
/// A folder that is already gone (a Finder deletion) is success, not an error — the same
|
|
/// end state a purge would have produced.
|
|
@Test func purgeOfAnAlreadyMissingFolderSucceedsSilently() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let gone = fixture.url("A.kanban/\(Ident.lane1)")
|
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)"))
|
|
|
|
try BoardWriter.purgeItem(at: gone)
|
|
}
|
|
|
|
@Test func purgeRefusesANonUUIDShapedFolder() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = try fixture.item("A.kanban", Item.board)
|
|
|
|
let error = writeFailure { try BoardWriter.purgeItem(at: root) }
|
|
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"))
|
|
}
|
|
|
|
@Test func purgeDoesNotRequirePriorTombstone() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let folder = try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Live"))
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane1)).deleted == .missing)
|
|
|
|
try BoardWriter.purgeItem(at: folder)
|
|
|
|
#expect(!fixture.exists(Ident.lane1))
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
// MARK: - Import attachments
|
|
|
|
/// `BoardWriter.importAttachments`: the write side of 01-storage-format.md § Attachments —
|
|
/// never refuses a drop, auto-renames Finder-style on collision, and stops a multi-file batch
|
|
/// at the first failure without undoing what already landed.
|
|
struct BoardWriterImportAttachmentsTests {
|
|
/// A bare UUID-shaped card folder — `importAttachments` never reads its `index.md`, so a
|
|
/// minimal one (like every other card these suites mint) is as good a target as a fully
|
|
/// loaded one.
|
|
private func card(_ fixture: WriterFixture) throws -> URL {
|
|
try fixture.item(Ident.card1, Item.rich(order: "1024", title: "Card"))
|
|
}
|
|
|
|
@Test func firstImportCreatesAttachmentsAndLandsTheOriginalName() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
let bytes = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])
|
|
let source = try fixture.file("sources/shot.png", bytes)
|
|
|
|
let landed = try BoardWriter.importAttachments([source], intoCard: card)
|
|
|
|
#expect(landed == [ImportedAttachment(sourceURL: source, fileName: "shot.png")])
|
|
#expect(try fixture.data("\(Ident.card1)/attachments/shot.png") == bytes)
|
|
// The source is a copy's origin, never its casualty.
|
|
#expect(try fixture.data("sources/shot.png") == bytes)
|
|
}
|
|
|
|
/// Three separate drops of a file named `shot.png`: never overwritten, never refused — each
|
|
/// lands under the next free Finder-style name, and all three coexist afterwards.
|
|
@Test func collidingImportsAutoRenameFinderStyle() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
let first = try fixture.file("sources/1/shot.png", Data([0x01]))
|
|
let second = try fixture.file("sources/2/shot.png", Data([0x02]))
|
|
let third = try fixture.file("sources/3/shot.png", Data([0x03]))
|
|
|
|
let landed1 = try BoardWriter.importAttachments([first], intoCard: card)
|
|
let landed2 = try BoardWriter.importAttachments([second], intoCard: card)
|
|
let landed3 = try BoardWriter.importAttachments([third], intoCard: card)
|
|
|
|
#expect(landed1.map(\.fileName) == ["shot.png"])
|
|
#expect(landed2.map(\.fileName) == ["shot 2.png"])
|
|
#expect(landed3.map(\.fileName) == ["shot 3.png"])
|
|
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot 2.png", "shot 3.png", "shot.png"])
|
|
#expect(try fixture.data("\(Ident.card1)/attachments/shot.png") == Data([0x01]))
|
|
#expect(try fixture.data("\(Ident.card1)/attachments/shot 2.png") == Data([0x02]))
|
|
#expect(try fixture.data("\(Ident.card1)/attachments/shot 3.png") == Data([0x03]))
|
|
}
|
|
|
|
/// A name with no extension at all suffixes directly — `"notes"` → `"notes 2"`, not
|
|
/// `"notes 2."` or some other extension-shaped artifact.
|
|
@Test func anExtensionLessCollisionRenamesToNotes2() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
let first = try fixture.file("sources/1/notes", Data("first".utf8))
|
|
let second = try fixture.file("sources/2/notes", Data("second".utf8))
|
|
|
|
_ = try BoardWriter.importAttachments([first], intoCard: card)
|
|
let landed = try BoardWriter.importAttachments([second], intoCard: card)
|
|
|
|
#expect(landed.map(\.fileName) == ["notes 2"])
|
|
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["notes", "notes 2"])
|
|
}
|
|
|
|
/// `fileExists` is true for a directory as much as a file: a subfolder that happens to share
|
|
/// the incoming name blocks it exactly like a file would, and is never touched by the import.
|
|
@Test func aFileNamedLikeAnExistingSubfolderAlsoRenames() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
try fixture.file("\(Ident.card1)/attachments/shot.png/inner.txt", Data("hand-made".utf8))
|
|
let source = try fixture.file("sources/shot.png", Data([0x09]))
|
|
|
|
let landed = try BoardWriter.importAttachments([source], intoCard: card)
|
|
|
|
#expect(landed.map(\.fileName) == ["shot 2.png"])
|
|
#expect(try fixture.data("\(Ident.card1)/attachments/shot 2.png") == Data([0x09]))
|
|
#expect(try fixture.entryNames("\(Ident.card1)/attachments/shot.png") == ["inner.txt"])
|
|
}
|
|
|
|
/// One call, three sources, two of which collide with what is already there and with each
|
|
/// other in turn — the returned names track input order, not landing order.
|
|
@Test func multiFileImportInOneCallReturnsLandedNamesInInputOrder() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
try fixture.file("\(Ident.card1)/attachments/shot.png", Data([0x00]))
|
|
let shot = try fixture.file("sources/1/shot.png", Data([0x01]))
|
|
let notes = try fixture.file("sources/2/notes.txt", Data([0x02]))
|
|
let shotAgain = try fixture.file("sources/3/shot.png", Data([0x03]))
|
|
|
|
let landed = try BoardWriter.importAttachments([shot, notes, shotAgain], intoCard: card)
|
|
|
|
#expect(landed.map(\.fileName) == ["shot 2.png", "notes.txt", "shot 3.png"])
|
|
#expect(landed.map(\.sourceURL) == [shot, notes, shotAgain])
|
|
#expect(try fixture.entryNames("\(Ident.card1)/attachments")
|
|
== ["notes.txt", "shot 2.png", "shot 3.png", "shot.png"])
|
|
}
|
|
|
|
/// Refused before any copy for that file: the failing source is named, nothing of its lands,
|
|
/// and — because each import is its own completed write — the file that landed *before* it
|
|
/// in the same batch stays landed rather than being rolled back.
|
|
@Test func anUnreadableSourceRefusesNamingItAndStopsTheBatch() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
let first = try fixture.file("sources/shot.png", Data([0x01]))
|
|
let missing = fixture.url("sources").appendingPathComponent("missing.png")
|
|
let third = try fixture.file("sources/third.png", Data([0x03]))
|
|
|
|
let error = writeFailure {
|
|
_ = try BoardWriter.importAttachments([first, missing, third], intoCard: card)
|
|
}
|
|
guard case .unreadable = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(error?.path == missing.path)
|
|
#expect(error?.operation == .importAttachment(filename: "missing.png"))
|
|
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot.png"])
|
|
}
|
|
|
|
/// Attachments belong to cards: the shape guard `deleteItem`/`restoreItem`/`purgeItem` share
|
|
/// refuses a board root (or any non-UUID-shaped folder) before `attachments/` is even
|
|
/// considered.
|
|
@Test func importIntoANonUUIDShapedFolderIsRefused() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let root = try fixture.item("A.kanban", Item.board)
|
|
let source = try fixture.file("sources/shot.png", Data([0x01]))
|
|
|
|
let error = writeFailure {
|
|
_ = try BoardWriter.importAttachments([source], intoCard: root)
|
|
}
|
|
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/attachments"))
|
|
}
|
|
|
|
/// "Never refuses the drop" is policy, not an I/O guarantee: a copy that genuinely cannot
|
|
/// land — `attachments/` made unwritable here, standing in for a full disk or a permissions
|
|
/// error — surfaces as `.io` naming the source file, and no partial file is left behind.
|
|
@Test func aFailedCopyLeavesNoPartialAttachment() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
let existing = try fixture.file("sources/existing.png", Data([0x00]))
|
|
_ = try BoardWriter.importAttachments([existing], intoCard: card)
|
|
let attachmentsFolder = fixture.url(Ident.card1).appendingPathComponent("attachments")
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: attachmentsFolder.path)
|
|
|
|
let source = try fixture.file("sources/shot.png", Data([0x01]))
|
|
let error = writeFailure {
|
|
_ = try BoardWriter.importAttachments([source], intoCard: card)
|
|
}
|
|
guard case .io = error?.reason else {
|
|
Issue.record("expected .io, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
#expect(error?.path == source.path)
|
|
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["existing.png"])
|
|
}
|
|
}
|
|
|
|
// MARK: - List attachments
|
|
|
|
/// `BoardWriter.listAttachments`: the flat, top-level-files-only read (01-storage-format.md §
|
|
/// Attachments) that never writes anything, not even `attachments/` itself.
|
|
struct BoardWriterListAttachmentsTests {
|
|
private func card(_ fixture: WriterFixture) throws -> URL {
|
|
try fixture.item(Ident.card1, Item.rich(order: "1024", title: "Card"))
|
|
}
|
|
|
|
/// Subfolders and hidden files never surface, and survive both the import and the listing
|
|
/// untouched; the sort is Finder's numeric order, not plain string order (`"shot 2.png"`
|
|
/// before `"shot 10.png"`, which a byte-wise sort would put the other way).
|
|
@Test func listingIsFlatExcludesHiddenFilesAndSortsFinderStyle() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
let shot2 = try fixture.file("sources/1/shot 2.png", Data([0x01]))
|
|
let shot10 = try fixture.file("sources/2/shot 10.png", Data([0x02]))
|
|
_ = try BoardWriter.importAttachments([shot2, shot10], intoCard: card)
|
|
try fixture.file("\(Ident.card1)/attachments/sub/inside.txt", Data("hand-made".utf8))
|
|
try fixture.file("\(Ident.card1)/attachments/.DS_Store", Data("hidden".utf8))
|
|
|
|
let listing = try BoardWriter.listAttachments(ofCard: card)
|
|
|
|
#expect(listing == ["shot 2.png", "shot 10.png"])
|
|
#expect(try fixture.entryNames("\(Ident.card1)/attachments/sub") == ["inside.txt"])
|
|
#expect(fixture.exists("\(Ident.card1)/attachments/.DS_Store"))
|
|
}
|
|
|
|
/// Nothing imported yet is an ordinary state, not an error — `attachments/` need not exist
|
|
/// for a card to list cleanly as empty.
|
|
@Test func aMissingAttachmentsFolderListsAsEmpty() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try card(fixture)
|
|
|
|
#expect(try BoardWriter.listAttachments(ofCard: card) == [])
|
|
#expect(!fixture.exists("\(Ident.card1)/attachments"))
|
|
}
|
|
|
|
/// Unlike a missing `attachments/`, a missing *card* is a loud error — the caller asked
|
|
/// about something that is not there.
|
|
@Test func aMissingCardFolderIsALoudUnreadableError() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let missing = fixture.url(Ident.card1)
|
|
|
|
let error = writeFailure { _ = try BoardWriter.listAttachments(ofCard: missing) }
|
|
guard case .unreadable = error?.reason else {
|
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
|
return
|
|
}
|
|
}
|
|
}
|