Implement create operations for board, lane, and card
createBoard fills or mints a .kanban (or extension-less) folder and refuses to clobber an existing board; createLane/createCard mint fresh lowercase-UUIDv4 folders and append at max+1024 among visible siblings via the same strict scan renumber uses (extracted, shared). New files carry schema/title?/order?/created/modified — created==modified, no modified-by — written LF through the same atomic temp+rename path. The editability pre-flight is now scoped to operations that rewrite siblings: renumber refuses on an uneditable sibling, a create beside one proceeds. Schema constant unified on BoardLoader.supportedSchema. Info.plist declares the .kanban document type (UTI conforming to com.apple.package) so Finder treats a board as one document. 20 new unit tests; 196 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -26,5 +26,39 @@
|
||||
<string>© 2026 rzen</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>dev.rzen.indie.kanban-board</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>com.apple.package</string>
|
||||
<string>public.directory</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Lanework Board</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<string>kanban</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Lanework Board</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>dev.rzen.indie.kanban-board</string>
|
||||
</array>
|
||||
<key>LSTypeIsPackage</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -30,9 +30,11 @@ import os
|
||||
public enum BoardLoader: Sendable {
|
||||
|
||||
/// Schema version this app understands; anything higher fails fast
|
||||
/// (01-storage-format.md § Malformed input). `fileprivate` rather than `private`: also
|
||||
/// read by `BoardLoadError.Reason.description` below, in this same file.
|
||||
fileprivate static let supportedSchema = 1
|
||||
/// (01-storage-format.md § Malformed input). Internal rather than `private`: read by
|
||||
/// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter`
|
||||
/// stamps into files it creates — one symbol, so the app can never write a file its own
|
||||
/// loader would reject as newer-than-supported.
|
||||
static let supportedSchema = 1
|
||||
|
||||
/// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense
|
||||
/// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
|
||||
|
||||
@@ -114,6 +114,163 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
/// Creates a new board: the folder (if it does not already exist) and its `index.md` — the
|
||||
/// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two
|
||||
/// callers: template instantiation, which passes the user-chosen document name as `title`
|
||||
/// so the window title and the Finder name start out matching (§ Board naming), and a bare
|
||||
/// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name.
|
||||
///
|
||||
/// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing
|
||||
/// *empty* folder is simply filled in, since that is exactly what an interrupted create or
|
||||
/// a hand-made folder looks like.
|
||||
/// - **Refuses to clobber an existing board**: if `index.md` is already there, the call
|
||||
/// fails `.io` naming the path and the existing file is never touched — a create must
|
||||
/// never overwrite a board that already exists.
|
||||
/// - `title == nil` writes no `title` key at all — the folder-name fallback (§ Board
|
||||
/// naming) is a *missing* key, not an empty string, which would be a real (if blank) title.
|
||||
/// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order` —
|
||||
/// that field belongs to lanes and cards, never the board root.
|
||||
/// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
|
||||
/// (§ Document packaging, "Extension-less board folders still open") — this call never
|
||||
/// looks at `rootURL`'s extension.
|
||||
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
|
||||
let operation = "create board"
|
||||
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
|
||||
|
||||
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
|
||||
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here"))
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: rootURL.path,
|
||||
reason: .io(message: "could not create board folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
|
||||
try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation)
|
||||
}
|
||||
|
||||
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
||||
/// `rootURL`, appends it after the board's current visible lanes, and writes its
|
||||
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
|
||||
/// the shared mechanics.
|
||||
public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
||||
try createChild(inParent: rootURL, title: title, operation: "create lane")
|
||||
}
|
||||
|
||||
/// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
|
||||
/// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`.
|
||||
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
|
||||
/// mechanics.
|
||||
public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
||||
try createChild(inParent: laneURL, title: title, operation: "create card")
|
||||
}
|
||||
|
||||
/// The shared body of `createLane`/`createCard` — a lane under a board and a card under a
|
||||
/// lane are the same operation one level apart (01-storage-format.md § Fractal layout,
|
||||
/// "Level is position"): check the parent, place the new identity after the current visible
|
||||
/// siblings, mint it, write its file.
|
||||
///
|
||||
/// - **The parent must already exist as a directory.** Anything else — missing, or a file
|
||||
/// where a folder belongs — is a loud `.unreadable` naming `parentFolder` up front, never
|
||||
/// a silent `mkdir` into a broken location.
|
||||
/// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by
|
||||
/// `visibleSiblings(of:operation:)` — the identical strict scan `renumberVisibleChildren`
|
||||
/// uses, so the two can never disagree about who counts as a sibling. An empty parent's
|
||||
/// first child lands at `1024`, the board convention (01-storage-format.md § Ordering).
|
||||
/// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created,
|
||||
/// then `index.md` is written, as two separate filesystem operations — there is no atomic
|
||||
/// "create a directory with content already in it" primitive to reach for. A crash between
|
||||
/// the two leaves a UUID-shaped folder with no `index.md`, exactly the shape
|
||||
/// `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own
|
||||
/// `fileExists` check) already tolerate — see that type's doc comment.
|
||||
private static func createChild(
|
||||
inParent parentFolder: URL,
|
||||
title: String?,
|
||||
operation: String
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try checkIsDirectory(parentFolder, operation: operation)
|
||||
|
||||
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
||||
let order = Ranks.append(toVisible: siblings.map(\.order))
|
||||
|
||||
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation)
|
||||
|
||||
return ItemID(rawValue: folder.lastPathComponent)
|
||||
}
|
||||
|
||||
/// The frontmatter text for a file the app is minting outright — never a rewrite, so this
|
||||
/// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior
|
||||
/// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and
|
||||
/// `modified` share one `Date()` so the two stamps are identical, not merely close;
|
||||
/// `modified-by` is never written, matching the engine's absence-means-app-authored
|
||||
/// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
|
||||
/// (only when supplied — `nil` for a board, always present for a lane/card), `created`,
|
||||
/// `modified` — is simply the order `set` is called in, since each call appends a fresh key
|
||||
/// before the closing delimiter of an otherwise-empty document.
|
||||
private static func newDocumentText(title: String?, order: Double?) -> String {
|
||||
var document = FrontmatterDocument(body: "")
|
||||
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
|
||||
if let title {
|
||||
document.set(FrontmatterKeys.title, to: .string(title))
|
||||
}
|
||||
if let order {
|
||||
document.set(FrontmatterKeys.order, to: .double(order))
|
||||
}
|
||||
let now = Date()
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
document.set(FrontmatterKeys.modified, to: .date(now))
|
||||
return document.serialized()
|
||||
}
|
||||
|
||||
/// A fresh lowercase-UUIDv4 folder under `parentFolder` — the folder-naming convention
|
||||
/// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase
|
||||
/// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match
|
||||
/// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. A freshly minted UUID
|
||||
/// already existing is astronomically unlikely — 122 bits of randomness per mint — but
|
||||
/// checked for and re-minted anyway rather than assumed away; the loop body is trivial
|
||||
/// precisely because the case it handles essentially never fires.
|
||||
private static func mintUUIDFolder(in parentFolder: URL, operation: String) throws(BoardWriteError) -> URL {
|
||||
var candidate: URL
|
||||
repeat {
|
||||
candidate = parentFolder.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
||||
} while FileManager.default.fileExists(atPath: candidate.path)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: candidate.path,
|
||||
reason: .io(message: "could not create folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// The parent-exists check `createChild` runs before touching the filesystem any further —
|
||||
/// shared instead of folded into `createChild` because "does this folder exist" has nothing
|
||||
/// to do with siblings or minting, and inlining it would bury the one thing a caller most
|
||||
/// needs to see at a glance: a missing or wrong-shaped parent is rejected before anything
|
||||
/// else happens.
|
||||
private static func checkIsDirectory(_ url: URL, operation: String) throws(BoardWriteError) {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
|
||||
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent folder does not exist"))
|
||||
}
|
||||
guard isDirectory.boolValue else {
|
||||
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent is not a directory"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Renumber
|
||||
|
||||
/// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback
|
||||
@@ -121,20 +278,14 @@ public enum BoardWriter: Sendable {
|
||||
/// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the
|
||||
/// parent is a lane (renumbering its cards) or the board root (renumbering its lanes).
|
||||
///
|
||||
/// - **Runs over loaded, valid children.** Every UUID-shaped child folder holding an
|
||||
/// `index.md` is parsed first; one that fails to parse, or that lacks a usable `order`,
|
||||
/// fails the whole operation before anything is written. A renumber is bookkeeping inside
|
||||
/// a user action that already succeeded in principle — it must not be the thing that
|
||||
/// discovers a broken sibling halfway through rewriting the lane.
|
||||
/// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is
|
||||
/// *present* is not counted, not sorted, and not rewritten — presence, not validity, is
|
||||
/// the test, exactly as `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is
|
||||
/// absence to both).
|
||||
/// - **Runs over loaded, valid children** (`visibleSiblings(of:operation:)`): one that fails
|
||||
/// to parse, or that lacks a usable `order`, fails the whole operation before anything is
|
||||
/// written. A renumber is bookkeeping inside a user action that already succeeded in
|
||||
/// principle — it must not be the thing that discovers a broken sibling halfway through
|
||||
/// rewriting the lane.
|
||||
/// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
|
||||
/// ascending, folder name breaking ties) — the same rule the loader sorts by, so a
|
||||
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves.
|
||||
/// - **Strays are untouched**: non-UUID-shaped folders and UUID-shaped folders without an
|
||||
/// `index.md` are skipped here for the same reasons `BoardLoader` skips them.
|
||||
///
|
||||
/// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some
|
||||
/// siblings renumbered and some not — every `order` still a valid float, display order
|
||||
@@ -142,7 +293,42 @@ public enum BoardWriter: Sendable {
|
||||
/// noted in § Ordering, which the deterministic tie-break exists to make harmless.
|
||||
public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) {
|
||||
let operation = "renumber children"
|
||||
let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true)
|
||||
|
||||
let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
|
||||
for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) {
|
||||
try updateIndex(inItemFolder: child.folder, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create
|
||||
/// operations' order assignment — extracted into one place so the two can never disagree
|
||||
/// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an
|
||||
/// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` — the same level-detection
|
||||
/// rule the loader uses), each parsed strictly:
|
||||
///
|
||||
/// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is
|
||||
/// *present* is not counted — presence, not validity, is the test, exactly as
|
||||
/// `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both).
|
||||
/// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
|
||||
/// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning
|
||||
/// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
|
||||
/// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming
|
||||
/// that sibling's file, before anything is written — the same discover-before-you-write
|
||||
/// guarantee `renumberVisibleChildren`'s batch depends on.
|
||||
/// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
|
||||
/// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
|
||||
/// unwritable sibling halfway through the batch), the creates pass `false` — a create
|
||||
/// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
|
||||
/// normally (01-storage-format.md § Frontmatter) must not block creating a new item
|
||||
/// beside it.
|
||||
private static func visibleSiblings(
|
||||
of parentFolder: URL,
|
||||
operation: String,
|
||||
requireEditable: Bool
|
||||
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
|
||||
let candidates: [URL]
|
||||
do {
|
||||
candidates = try BoardLoader.directoryCandidates(in: parentFolder)
|
||||
@@ -161,7 +347,9 @@ public enum BoardWriter: Sendable {
|
||||
|
||||
let document = try readDocument(at: indexURL, operation: operation)
|
||||
guard document.deleted.isMissing else { continue }
|
||||
if requireEditable {
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
}
|
||||
|
||||
switch document.order {
|
||||
case .missing:
|
||||
@@ -180,13 +368,7 @@ public enum BoardWriter: Sendable {
|
||||
visible.append((folder: folder, order: order))
|
||||
}
|
||||
}
|
||||
|
||||
let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
|
||||
for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) {
|
||||
try updateIndex(inItemFolder: child.folder, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
@@ -593,3 +593,330 @@ struct BoardWriterLoaderIntegrationTests {
|
||||
#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 == "create lane")
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user