Files
lanework/KanbanTests/BoardImportWriteTests.swift
T
rzen b0c134a896 A board leaves as one file and comes back as one — headings are lanes, rows are cards, position is the order
File ▸ Export ▸ writes the frontmost board as Obsidian Kanban Markdown, a
plain Markdown outline, or RFC 4180 CSV; File ▸ Import Board… reads any of
the three back into a fresh board, format detected rather than asked. Every
format encodes order as document position, so an export writes no ranks and
an import mints them in parse order on the ordinary create path. Lossy
exports post a warning-tone loss row naming the comments and attachments the
destination cannot carry. Convert-once: nothing watches, nothing merges.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 08:38:06 -04:00

320 lines
14 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// **The disk half of File ▸ Import Board…** (15-import-export.md) — a parsed `InterchangeBoard`
/// materialized into a real `.kanban` folder, read back through the ordinary loader.
///
/// The pure half (parsers, serializers, detection) is `InterchangeTests.swift`. What this file pins is
/// everything that only becomes true once the Writer has run: the rank ladder the create path mints, the
/// board conventions an import gets for free, and the construct-then-clean atomicity it borrows from
/// `TemplateEngine`.
@Suite("Board import — materialization")
struct BoardImportWriteTests {
// MARK: Fixture
/// A scratch directory, removed on the way out.
private struct Scratch {
let root: URL
init() throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("BoardImportTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func tearDown() {
try? FileManager.default.removeItem(at: root)
}
func url(_ name: String) -> URL {
root.appendingPathComponent(name, isDirectory: true)
}
}
private func sample() -> InterchangeBoard {
InterchangeBoard(title: "Parsed Title", lanes: [
InterchangeLane(title: "To Do", cards: [
InterchangeCard(title: "Fix login", body: "The button does nothing."),
InterchangeCard(title: "Ship the beta"),
InterchangeCard(title: nil, body: "an untitled card")
]),
InterchangeLane(title: "Done")
])
}
// MARK: Tests
@Test("An imported board loads as an ordinary board, in parse order")
func materializedBoardLoads() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let destination = scratch.url("Imported.kanban")
try BoardImporter.materialize(sample(), to: destination, title: "Imported")
let model = try BoardLoader.load(boardRoot: destination).model
#expect(model.schema == BoardLoader.supportedSchema)
// **The save panel's name wins over the parsed one** (01-storage-format.md § Board naming).
#expect(model.title.value == "Imported")
#expect(model.lanes.map(\.title.value) == ["To Do", "Done"])
#expect(model.lanes[0].cards.map(\.title.value) == ["Fix login", "Ship the beta", nil])
#expect(model.lanes[0].cards[0].body == "The button does nothing.")
#expect(model.lanes[0].cards[1].body.isEmpty)
#expect(model.lanes[0].cards[2].body == "an untitled card")
#expect(model.lanes[1].cards.isEmpty)
}
@Test("Ranks are the create path's own 1024 ladder, in parse order")
func ranksAreMintedWithGaps() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let destination = scratch.url("Ranks.kanban")
try BoardImporter.materialize(sample(), to: destination, title: "Ranks")
let model = try BoardLoader.load(boardRoot: destination).model
#expect(model.lanes.map(\.order) == [1024, 2048])
#expect(model.lanes[0].cards.map(\.order) == [1024, 2048, 3072])
}
@Test("Folder names are fresh lowercase UUIDs at every level")
func identitiesAreMinted() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let destination = scratch.url("Ids.kanban")
try BoardImporter.materialize(sample(), to: destination, title: "Ids")
let model = try BoardLoader.load(boardRoot: destination).model
var seen = Set<String>()
for lane in model.lanes {
#expect(BoardLoader.isUUIDShaped(lane.id.rawValue))
#expect(lane.id.rawValue == lane.id.rawValue.lowercased())
#expect(seen.insert(lane.id.rawValue).inserted)
for card in lane.cards {
#expect(BoardLoader.isUUIDShaped(card.id.rawValue))
#expect(card.id.rawValue == card.id.rawValue.lowercased())
#expect(seen.insert(card.id.rawValue).inserted)
}
}
}
@Test("An imported board is born with the conventions every new board gets")
func bornWithTheUsualFurniture() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let destination = scratch.url("Furniture.kanban")
try BoardImporter.materialize(sample(), to: destination, title: "Furniture")
let manager = FileManager.default
#expect(manager.fileExists(atPath: destination.appendingPathComponent(".gitignore").path))
#expect(manager.fileExists(atPath: destination.appendingPathComponent("CLAUDE.md").path))
#expect(!manager.fileExists(atPath: destination.appendingPathComponent(".trash").path))
}
@Test("An occupied destination is refused, never clobbered")
func occupiedDestinationRefuses() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let destination = scratch.url("Taken.kanban")
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
let marker = destination.appendingPathComponent("mine.txt")
try Data("keep me".utf8).write(to: marker)
#expect(throws: BoardImporter.Failure.self) {
try BoardImporter.materialize(sample(), to: destination, title: "Taken")
}
// The refusal never touched what was there.
#expect(try Data(contentsOf: marker) == Data("keep me".utf8))
#expect(!FileManager.default.fileExists(atPath: destination.appendingPathComponent("index.md").path))
}
@Test("A cancelled import leaves nothing behind")
func cancelledImportRemovesThePartial() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let destination = scratch.url("Cancelled.kanban")
// False for the pre-flight and the first lane, true from the first card on — so the board
// folder and one lane really are on disk when the cancel lands.
var calls = 0
let isCancelled = { () -> Bool in
calls += 1
return calls > 2
}
var caught: BoardImporter.Failure?
do {
try BoardImporter.materialize(sample(), to: destination, title: "Cancelled", isCancelled: isCancelled)
} catch {
caught = error
}
#expect(caught == .cancelled)
#expect(!FileManager.default.fileExists(atPath: destination.path))
}
@Test("A board with no lanes still materializes as a board")
func emptyBoardMaterializes() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let destination = scratch.url("Empty.kanban")
try BoardImporter.materialize(InterchangeBoard(), to: destination, title: "Empty")
let model = try BoardLoader.load(boardRoot: destination).model
#expect(model.lanes.isEmpty)
#expect(model.title.value == "Empty")
}
// MARK: Reading
@Test("A file read end to end lands as the board its bytes describe")
func readParsesTheFile() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let file = scratch.root.appendingPathComponent("Team Board.md")
try Data("---\n\nkanban-plugin: board\n\n---\n\n## To Do\n\n- [ ] A\n".utf8).write(to: file)
let source = try BoardImporter.read(contentsOf: file)
#expect(source.format == .obsidianKanban)
#expect(source.title == "Team Board")
#expect(source.board.lanes.map(\.title) == ["To Do"])
}
@Test("Bytes that are not UTF-8 are refused with a sentence, not imported as mojibake")
func nonUTF8IsRefused() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let file = scratch.root.appendingPathComponent("latin.csv")
try Data([0xFF, 0xFE, 0x41, 0x00]).write(to: file)
var caught: BoardImporter.Failure?
do {
_ = try BoardImporter.read(contentsOf: file)
} catch {
caught = error
}
guard case let .failed(error) = caught else {
Issue.record("expected a failure, got \(String(describing: caught))")
return
}
#expect(error.operation == .importBoard(fileName: "latin.csv"))
#expect(BannerCenter.headline(for: error).hasPrefix("Couldn't import 'latin.csv'"))
}
@Test("A missing file is a failure, not a crash")
func missingFileIsRefused() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
#expect(throws: BoardImporter.Failure.self) {
_ = try BoardImporter.read(contentsOf: scratch.root.appendingPathComponent("nope.md"))
}
}
// MARK: The whole trip
@Test("Export a real board, import it back, and the lanes and cards survive")
func exportThenImportThroughDisk() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
// Build a real board the ordinary way, so the export reads a genuine snapshot.
let origin = scratch.url("Origin.kanban")
try BoardWriter.createBoard(at: origin, title: "Origin")
let todo = try BoardWriter.createLane(inBoard: origin, title: "To Do")
let done = try BoardWriter.createLane(inBoard: origin, title: "Done")
let first = try BoardWriter.createCard(
inLane: origin.appendingPathComponent(todo.rawValue, isDirectory: true),
title: "Fix login"
)
try BoardWriter.writeBody(
inItemFolder: origin
.appendingPathComponent(todo.rawValue, isDirectory: true)
.appendingPathComponent(first.rawValue, isDirectory: true),
body: "line one\nline two"
)
_ = try BoardWriter.createCard(
inLane: origin.appendingPathComponent(todo.rawValue, isDirectory: true),
title: "Ship the beta"
)
_ = try BoardWriter.createCard(
inLane: origin.appendingPathComponent(done.rawValue, isDirectory: true),
title: "Write the changelog"
)
let snapshot = try BoardLoader.load(boardRoot: origin).model
#expect(snapshot.lanes.map(\.title.value) == ["To Do", "Done"], "the fixture board itself")
for format in InterchangeFormat.allCases {
let exported = BoardExporter.text(
for: InterchangeBoard.from(snapshot, titled: "Origin"),
format: format
)
let file = scratch.root.appendingPathComponent("Origin-\(format.rawValue).\(format.fileExtension)")
try BoardExporter.write(exported, to: file, boardTitle: "Origin")
let source = try BoardImporter.read(contentsOf: file)
#expect(source.format == format)
let destination = scratch.url("Back-\(format.rawValue).kanban")
try BoardImporter.materialize(source.board, to: destination, title: "Back")
let reloaded = try BoardLoader.load(boardRoot: destination).model
// CSV has no row for an empty lane, and this board has none — every lane here carries
// cards, so all three formats must reproduce the whole board.
#expect(reloaded.lanes.map(\.title.value) == ["To Do", "Done"], "\(format.rawValue) lanes")
#expect(
reloaded.lanes.map { $0.cards.map(\.title.value) }
== [["Fix login", "Ship the beta"], ["Write the changelog"]],
"\(format.rawValue) cards, in order"
)
#expect(
reloaded.lanes.first?.cards.first?.body == "line one\nline two",
"\(format.rawValue) body"
)
}
}
@Test("The omissions count reads comments and attachments off the snapshot")
func omissionsCounted() throws {
let scratch = try Scratch()
defer { scratch.tearDown() }
let root = scratch.url("Counted.kanban")
try BoardWriter.createBoard(at: root, title: "Counted")
let lane = try BoardWriter.createLane(inBoard: root, title: "L")
let laneFolder = root.appendingPathComponent(lane.rawValue, isDirectory: true)
let card = try BoardWriter.createCard(inLane: laneFolder, title: "A")
let cardFolder = laneFolder.appendingPathComponent(card.rawValue, isDirectory: true)
#expect(InterchangeOmissions.of(try BoardLoader.load(boardRoot: root).model).isEmpty)
// Two attachments and one comment, written the way the schema spells them.
let attachments = cardFolder.appendingPathComponent("attachments", isDirectory: true)
try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true)
try Data("a".utf8).write(to: attachments.appendingPathComponent("one.txt"))
try Data("b".utf8).write(to: attachments.appendingPathComponent("two.txt"))
let comment = cardFolder
.appendingPathComponent("comments", isDirectory: true)
.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
try FileManager.default.createDirectory(at: comment, withIntermediateDirectories: true)
try Data("---\nschema: 1\nkind: comment\n---\nhello\n".utf8)
.write(to: comment.appendingPathComponent("index.md"))
let omissions = InterchangeOmissions.of(try BoardLoader.load(boardRoot: root).model)
#expect(omissions == InterchangeOmissions(comments: 1, attachments: 2))
#expect(BannerCenter.exportOmissionsMessage(omissions, format: .csv)
== "Exported without 1 comment and 2 attachments — CSV carries neither")
}
}