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() 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") } }