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:
@@ -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