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:
2026-07-26 17:01:09 -04:00
parent 19f67bdb78
commit 8c0cec06ee
4 changed files with 567 additions and 22 deletions
+327
View File
@@ -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)
}
}