import Foundation import Testing @testable import Kanban /// **Import/export v1, the pure half** (15-import-export.md) — the three serializers, the three /// parsers, the detector, and the phrasing. Everything here is a function of a value: no board on disk, /// no store, no window, which is exactly what the `InterchangeBoard` seam exists to buy. /// /// The disk half — a parsed board materialized into a real `.kanban` folder — is /// `BoardImportWriteTests.swift`. // MARK: - Fixtures /// A three-lane board exercising every shape the writers have a rule for: a multi-line body, an empty /// lane, an untitled card, and a body carrying the characters each format has to escape. private func sampleBoard() -> InterchangeBoard { InterchangeBoard( title: "Roadmap", lanes: [ InterchangeLane(title: "To Do", cards: [ InterchangeCard(title: "Fix login", body: "The button does nothing\non the second click."), InterchangeCard(title: "Ship the beta") ]), InterchangeLane(title: "Doing"), InterchangeLane(title: "Done", cards: [ InterchangeCard(title: nil, body: "an untitled card"), InterchangeCard(title: "Comma, quote \" and all", body: "line one\n\nline three") ]) ] ) } /// The comparison every round-trip assertion makes: titles, lanes, cards, order, bodies — everything /// the interchange shape carries **except the two export-only stamps**, which no importer reads /// (`InterchangeCard.created`). private func structure(of board: InterchangeBoard) -> [[String]] { board.lanes.map { lane in [lane.title ?? "\u{0}nil"] + lane.cards.map { "\($0.title ?? "\u{0}nil")\u{1}\($0.body)" } } } // MARK: - Markdown export @Suite("Markdown export") struct MarkdownBoardWriterTests { @Test("The Obsidian flavour writes the plugin's frontmatter, ## lanes and - [ ] items") func obsidianShape() { let text = MarkdownBoardWriter.text( for: InterchangeBoard(title: "Roadmap", lanes: [ InterchangeLane(title: "To Do", cards: [InterchangeCard(title: "A", body: "note")]), InterchangeLane(title: "Done", cards: [InterchangeCard(title: "B")]) ]), flavor: .obsidianKanban ) #expect(text == """ --- kanban-plugin: board --- ## To Do - [ ] A note ## Done - [ ] B """) } @Test("The plain outline writes an H1 title, no frontmatter, and plain bullets") func outlineShape() { let text = MarkdownBoardWriter.text( for: InterchangeBoard(title: "Roadmap", lanes: [ InterchangeLane(title: "To Do", cards: [InterchangeCard(title: "A", body: "note")]) ]), flavor: .markdownOutline ) #expect(text == """ # Roadmap ## To Do - A note """) } @Test("An empty lane costs one blank line, not two") func emptyLaneSpacing() { let text = MarkdownBoardWriter.text( for: InterchangeBoard(lanes: [ InterchangeLane(title: "Empty"), InterchangeLane(title: "Next", cards: [InterchangeCard(title: "A")]) ]), flavor: .markdownOutline ) #expect(text == """ ## Empty ## Next - A """) } @Test("An untitled card writes a bare marker, never the word Untitled") func untitledCard() { let text = MarkdownBoardWriter.text( for: InterchangeBoard(lanes: [InterchangeLane(title: "L", cards: [InterchangeCard()])]), flavor: .obsidianKanban ) #expect(text.contains("\n- [ ]\n")) #expect(!text.localizedCaseInsensitiveContains("untitled")) } @Test("A blank line inside a body stays blank rather than gaining an indent") func blankBodyLineCarriesNoIndent() { let text = MarkdownBoardWriter.text( for: InterchangeBoard(lanes: [ InterchangeLane(title: "L", cards: [InterchangeCard(title: "A", body: "one\n\nthree")]) ]), flavor: .markdownOutline ) #expect(text.contains("- A\n one\n\n three\n")) } @Test("Nothing is ever exported checked, whatever the lane is called") func everythingIsUnchecked() { let text = MarkdownBoardWriter.text( for: InterchangeBoard(lanes: [ InterchangeLane(title: "Done", cards: [InterchangeCard(title: "Shipped")]) ]), flavor: .obsidianKanban ) #expect(text.contains("- [ ] Shipped")) #expect(!text.contains("[x]")) } } // MARK: - Markdown import @Suite("Markdown import") struct MarkdownBoardParserTests { @Test("H2s are lanes, bullets are cards, indented lines are bodies") func basicOutline() { let board = MarkdownBoardParser.parse(""" # Roadmap ## To Do - Fix login The button does nothing. - Ship the beta ## Done - Write the changelog """) #expect(board.title == "Roadmap") #expect(board.lanes.map(\.title) == ["To Do", "Done"]) #expect(board.lanes[0].cards.map(\.title) == ["Fix login", "Ship the beta"]) #expect(board.lanes[0].cards[0].body == "The button does nothing.") #expect(board.lanes[0].cards[1].body.isEmpty) #expect(board.lanes[1].cards.map(\.title) == ["Write the changelog"]) } @Test("Task markers are read and discarded, checked or not") func taskMarkersAreDropped() { let board = MarkdownBoardParser.parse(""" ## L - [ ] Open - [x] Closed - [X] Also closed """) #expect(board.lanes[0].cards.map(\.title) == ["Open", "Closed", "Also closed"]) } @Test("H1s are lanes when the document has no H2s") func h1LanesWhenNoH2s() { let board = MarkdownBoardParser.parse(""" # To Do - A # Done - B """) #expect(board.title == nil) #expect(board.lanes.map(\.title) == ["To Do", "Done"]) } @Test("Deeper headings become cards") func deeperHeadingsAreCards() { let board = MarkdownBoardParser.parse(""" ## To Do ### Task A some notes ### Task B """) #expect(board.lanes.count == 1) #expect(board.lanes[0].cards.map(\.title) == ["Task A", "Task B"]) #expect(board.lanes[0].cards[0].body == "some notes") } @Test("Content with no heading at all lands in one Imported lane") func headlessContentLandsInImported() { let board = MarkdownBoardParser.parse(""" - milk - eggs """) #expect(board.lanes.map(\.title) == [MarkdownBoardParser.defaultLaneTitle]) #expect(board.lanes[0].cards.map(\.title) == ["milk", "eggs"]) } @Test("Ordered lists are cards too") func orderedListItems() { let board = MarkdownBoardParser.parse(""" ## L 1. First 2) Second """) #expect(board.lanes[0].cards.map(\.title) == ["First", "Second"]) } @Test("Column-0 prose continues the paragraph, or starts a card after a blank line") func lazyContinuationVersusNewCard() { let board = MarkdownBoardParser.parse(""" ## L - Fix login still the same paragraph a separate thought """) #expect(board.lanes[0].cards.count == 2) #expect(board.lanes[0].cards[0].title == "Fix login") #expect(board.lanes[0].cards[0].body == "still the same paragraph") #expect(board.lanes[0].cards[1].title == "a separate thought") } @Test("The plugin's settings comment and archive separator never become cards") func pluginResidueIsSkipped() { let board = MarkdownBoardParser.parse(""" --- kanban-plugin: board --- ## To Do - [ ] A *** ## Archive - [ ] Old %% kanban:settings ``` {"kanban-plugin":"board"} ``` %% """) #expect(board.lanes.map(\.title) == ["To Do", "Archive"]) #expect(board.lanes[0].cards.map(\.title) == ["A"]) #expect(board.lanes[1].cards.map(\.title) == ["Old"]) } @Test("A column-0 fenced code block rides into one card rather than one card per line") func fencedCodeStaysWhole() { let board = MarkdownBoardParser.parse(""" ## L - Snippet ```swift let a = 1 let b = 2 ``` """) #expect(board.lanes[0].cards.count == 1) #expect(board.lanes[0].cards[0].body == "```swift\nlet a = 1\nlet b = 2\n```") } @Test("Nested indentation inside a body keeps its relative shape") func nestedIndentationSurvives() { let board = MarkdownBoardParser.parse(""" ## L - Parent intro deeper """) #expect(board.lanes[0].cards[0].body == "intro\n deeper") } @Test("An empty document is an empty board, not an invented lane") func emptyDocument() { #expect(MarkdownBoardParser.parse("").lanes.isEmpty) #expect(MarkdownBoardParser.parse("\n\n \n").lanes.isEmpty) } @Test("An untitled heading and an untitled bullet both parse as nil, never as a placeholder") func untitledShapesParseAsNil() { let board = MarkdownBoardParser.parse(""" ## - [ ] """) #expect(board.lanes.count == 1) #expect(board.lanes[0].title == nil) #expect(board.lanes[0].cards.count == 1) #expect(board.lanes[0].cards[0].title == nil) } @Test("CRLF input reads exactly as LF input does") func crlfIsNormalized() { let lf = MarkdownBoardParser.parse("## L\n\n- A\n body\n") let crlf = MarkdownBoardParser.parse("## L\r\n\r\n- A\r\n body\r\n") #expect(structure(of: lf) == structure(of: crlf)) } } // MARK: - CSV plumbing @Suite("CSV document") struct CSVDocumentTests { @Test("Only the four characters that force quoting get quoted") func quotingRule() { #expect(CSVDocument.field("plain") == "plain") #expect(CSVDocument.field("a,b") == "\"a,b\"") #expect(CSVDocument.field("say \"hi\"") == "\"say \"\"hi\"\"\"") #expect(CSVDocument.field("line\nbreak") == "\"line\nbreak\"") #expect(CSVDocument.field("") == "") } @Test("Records end with CRLF, as RFC 4180 requires") func crlfTerminators() { #expect(CSVDocument.encode([["a", "b"], ["c", "d"]]) == "a,b\r\nc,d\r\n") #expect(CSVDocument.encode([]).isEmpty) } @Test("Encode then decode is the identity on every awkward field") func roundTrip() { let records = [ ["lane", "title", "body"], ["To Do", "Comma, here", "quote \" and\nnewline"], ["", "", ""] ] #expect(CSVDocument.decode(CSVDocument.encode(records)) == records) } @Test("LF, CRLF and bare CR all terminate a record") func lineEndingTolerance() { #expect(CSVDocument.decode("a,b\nc,d") == [["a", "b"], ["c", "d"]]) #expect(CSVDocument.decode("a,b\r\nc,d") == [["a", "b"], ["c", "d"]]) #expect(CSVDocument.decode("a,b\rc,d") == [["a", "b"], ["c", "d"]]) } @Test("A trailing terminator makes no phantom record; a missing one loses nothing") func terminatorEdges() { #expect(CSVDocument.decode("a,b\r\n") == [["a", "b"]]) #expect(CSVDocument.decode("a,b") == [["a", "b"]]) #expect(CSVDocument.decode("").isEmpty) } @Test("A BOM is dropped and a mid-field quote is literal text") func lenientReads() { #expect(CSVDocument.decode("\u{FEFF}a,b") == [["a", "b"]]) #expect(CSVDocument.decode("say \"hi\",b") == [["say \"hi\"", "b"]]) } } // MARK: - CSV board @Suite("CSV board") struct CSVBoardTests { @Test("The header is the five columns, and the lane repeats per row") func exportShape() { let text = CSVBoardWriter.text(for: InterchangeBoard(lanes: [ InterchangeLane(title: "To Do", cards: [ InterchangeCard(title: "A"), InterchangeCard(title: "B", body: "note") ]) ])) #expect(text == "lane,title,body,created,modified\r\nTo Do,A,,,\r\nTo Do,B,note,,\r\n") } @Test("Stamps export as ISO 8601 UTC, absent ones as empty cells") func stampsExport() { let when = Date(timeIntervalSince1970: 1_754_000_000) let text = CSVBoardWriter.text(for: InterchangeBoard(lanes: [ InterchangeLane(title: "L", cards: [InterchangeCard(title: "A", created: when, modified: nil)]) ])) #expect(text.contains(",\(when.formatted(.iso8601)),\r\n")) } @Test("Header sniffing is case-insensitive and takes the common synonyms") func headerSynonyms() { #expect(CSVBoardParser.headerColumns(["Lane", "Title", "Body"]) == CSVBoardParser.Columns(lane: 0, title: 1, body: 2)) #expect(CSVBoardParser.headerColumns([" LIST ", "Name", "Notes"]) == CSVBoardParser.Columns(lane: 0, title: 1, body: 2)) #expect(CSVBoardParser.headerColumns(["Assignee", "Due"]) == nil) } @Test("Unknown columns are ignored rather than refused") func unknownColumnsIgnored() { let board = CSVBoardParser.parse(""" Assignee,Status,Name,Due\r rz,To Do,Fix login,2026-09-01\r """) #expect(board.lanes.map(\.title) == ["To Do"]) #expect(board.lanes[0].cards.map(\.title) == ["Fix login"]) } @Test("A headerless file takes column 0 as the title and claims nothing else") func headerlessTakesFirstColumn() { let board = CSVBoardParser.parse("Fix login,rz\r\nShip the beta,rz\r\n") #expect(board.lanes.map(\.title) == [MarkdownBoardParser.defaultLaneTitle]) #expect(board.lanes[0].cards.map(\.title) == ["Fix login", "Ship the beta"]) #expect(board.lanes[0].cards.allSatisfy { $0.body.isEmpty }) } @Test("No lane column, or an empty lane cell, lands in the Imported lane") func missingLaneFallsBack() { let noColumn = CSVBoardParser.parse("title\r\nA\r\nB\r\n") #expect(noColumn.lanes.map(\.title) == [MarkdownBoardParser.defaultLaneTitle]) let emptyCell = CSVBoardParser.parse("lane,title\r\n,A\r\nTo Do,B\r\n") #expect(emptyCell.lanes.map(\.title) == [MarkdownBoardParser.defaultLaneTitle, "To Do"]) } @Test("Lanes appear in first-appearance order and rows join the lane they name") func laneOrderIsFirstAppearance() { let board = CSVBoardParser.parse(""" lane,title\r Done,A\r To Do,B\r Done,C\r """) #expect(board.lanes.map(\.title) == ["Done", "To Do"]) #expect(board.lanes[0].cards.map(\.title) == ["A", "C"]) #expect(board.lanes[1].cards.map(\.title) == ["B"]) } @Test("A multi-line quoted body survives the trip through the table") func multilineBody() { let board = CSVBoardParser.parse("lane,title,body\r\nL,A,\"one\ntwo\"\r\n") #expect(board.lanes[0].cards[0].body == "one\ntwo") } @Test("A CRLF inside a quoted body folds to LF, because a board's files are LF") func quotedCRLFFolds() { let board = CSVBoardParser.parse("lane,title,body\r\nL,A,\"one\r\ntwo\"\r\n") #expect(board.lanes.count == 1) #expect(board.lanes[0].cards[0].body == "one\ntwo") } @Test("A short row imports its complete columns rather than trapping") func raggedRow() { let board = CSVBoardParser.parse("lane,title,body\r\nL,A\r\n") #expect(board.lanes[0].cards.map(\.title) == ["A"]) #expect(board.lanes[0].cards[0].body.isEmpty) } @Test("Wholly empty rows are dropped, and an empty file is an empty board") func emptyInput() { #expect(CSVBoardParser.parse("").lanes.isEmpty) #expect(CSVBoardParser.parse("lane,title\r\n,\r\n").lanes.isEmpty) } @Test("Stamps are never read back — an imported board is born today") func stampsAreNotImported() { let board = CSVBoardParser.parse("lane,title,body,created,modified\r\nL,A,,2020-01-01T00:00:00Z,2020-01-01T00:00:00Z\r\n") #expect(board.lanes[0].cards[0].created == nil) #expect(board.lanes[0].cards[0].modified == nil) } } // MARK: - Round trips @Suite("Round trips") struct InterchangeRoundTripTests { @Test("Obsidian Kanban: export then import preserves lanes, cards, order and bodies") func obsidianRoundTrip() { let board = sampleBoard() let text = BoardExporter.text(for: board, format: .obsidianKanban) let back = MarkdownBoardParser.parse(text) #expect(structure(of: back) == structure(of: board)) } @Test("Markdown outline: the same, plus the board title") func outlineRoundTrip() { let board = sampleBoard() let text = BoardExporter.text(for: board, format: .markdownOutline) let back = MarkdownBoardParser.parse(text) #expect(back.title == board.title) #expect(structure(of: back) == structure(of: board)) } @Test("CSV: the same, with untitled lanes landing in Imported") func csvRoundTrip() { // The one shape CSV cannot carry back is an untitled lane — it writes as an empty cell, which // reads as the fallback lane. The rest round-trips exactly, empty lane included… except that a // lane with no rows has nothing to write at all, which is the format's own grain. let board = InterchangeBoard(title: "Roadmap", lanes: [ InterchangeLane(title: "To Do", cards: [ InterchangeCard(title: "Fix login", body: "The button does nothing\non the second click."), InterchangeCard(title: "Ship the beta") ]), InterchangeLane(title: "Done", cards: [ InterchangeCard(title: "Comma, quote \" and all", body: "line one\n\nline three") ]) ]) let text = BoardExporter.text(for: board, format: .csv) let back = CSVBoardParser.parse(text) #expect(structure(of: back) == structure(of: board)) } @Test("An exported board detects as the format it was exported in") func exportsDetectAsThemselves() { let board = sampleBoard() for format in InterchangeFormat.allCases { let text = BoardExporter.text(for: board, format: format) let detected = InterchangeFormat.detect(text: text, fileName: "Roadmap.\(format.fileExtension)") #expect(detected == format, "\(format.rawValue) round-trips through detection") } } @Test("A second export of an imported document is byte-identical to the first") func exportIsStableUnderReimport() { let board = sampleBoard() for format in [InterchangeFormat.obsidianKanban, .markdownOutline] { let first = BoardExporter.text(for: board, format: format) let reparsed = MarkdownBoardParser.parse(first) // The Obsidian flavour drops the board title by design, so the second pass is compared // against a board carrying whatever the first pass could actually say. let second = BoardExporter.text(for: reparsed, format: format) #expect(first == second, "\(format.rawValue) is a fixed point") } } } // MARK: - Detection @Suite("Format detection") struct InterchangeFormatDetectionTests { @Test("The plugin's frontmatter marker is conclusive, whatever the file is called") func kanbanPluginMarkerWins() { let text = "---\n\nkanban-plugin: board\n\n---\n\n## L\n\n- [ ] A\n" #expect(InterchangeFormat.detect(text: text, fileName: "board.md") == .obsidianKanban) #expect(InterchangeFormat.detect(text: text, fileName: nil) == .obsidianKanban) #expect(InterchangeFormat.detect(text: text, fileName: "board.csv") == .obsidianKanban) } @Test("Frontmatter without the marker is an ordinary outline") func otherFrontmatterIsOutline() { let text = "---\ntitle: Notes\n---\n\n## L\n\n- A\n" #expect(InterchangeFormat.detect(text: text, fileName: "notes.md") == .markdownOutline) } @Test("A .csv extension outranks the shape sniff") func csvExtensionWins() { #expect(InterchangeFormat.detect(text: "title\nA\nB\n", fileName: "tasks.csv") == .csv) } @Test("A Markdown extension keeps a comma-heavy document out of the table reader") func markdownExtensionWins() { let prose = "One, two, three, four\nFive, six, seven, eight\n" #expect(InterchangeFormat.detect(text: prose, fileName: "notes.md") == .markdownOutline) #expect(InterchangeFormat.detect(text: prose, fileName: "notes.txt") == .markdownOutline) } @Test("An extension-less table is sniffed as CSV; an extension-less outline is not") func shapeSniff() { #expect(InterchangeFormat.detect(text: "lane,title\nTo Do,A\n", fileName: nil) == .csv) #expect(InterchangeFormat.detect(text: "## L\n\n- A\n", fileName: "board") == .markdownOutline) // One Markdown structural line vetoes the sniff outright, however comma-heavy the rest is. #expect(InterchangeFormat.detect(text: "a,b\n- item\nc,d\n", fileName: nil) == .markdownOutline) // A single column, a single record, and an empty file all fall through to the lenient parser. #expect(InterchangeFormat.detect(text: "just words\n", fileName: nil) == .markdownOutline) #expect(InterchangeFormat.detect(text: "a,b\n", fileName: nil) == .markdownOutline) #expect(InterchangeFormat.detect(text: "", fileName: nil) == .markdownOutline) } } // MARK: - Parsing entry point @Suite("Import parse") struct BoardImporterParseTests { @Test("The board title is the document's when it has one, the file's name otherwise") func titleFallback() { let outline = BoardImporter.parse(text: "# Real Title\n\n## L\n\n- A\n", fileName: "whatever.md") #expect(outline.title == "Real Title") let obsidian = BoardImporter.parse( text: "---\n\nkanban-plugin: board\n\n---\n\n## L\n\n- [ ] A\n", fileName: "Team Board.md" ) #expect(obsidian.title == "Team Board") let csv = BoardImporter.parse(text: "lane,title\r\nL,A\r\n", fileName: "export.csv") #expect(csv.title == "export") let nameless = BoardImporter.parse(text: "- A\n", fileName: nil) #expect(nameless.title == "Imported Board") } @Test("The save panel's suggestion is the title with the package extension") func suggestedName() { let source = BoardImporter.parse(text: "# Q3: ship/slip\n\n## L\n\n- A\n", fileName: "x.md") #expect(BoardImporter.suggestedFileName(for: source) == "Q3- ship-slip.kanban") } } // MARK: - Omissions @Suite("Export omissions") struct InterchangeOmissionsTests { @Test("A lossless export says nothing at all") func silentWhenNothingIsLost() { #expect(InterchangeOmissions().isEmpty) #expect(BannerCenter.exportOmissionsMessage(InterchangeOmissions(), format: .csv) == nil) } @Test("One kind names it and the format; both kinds fold into 'neither'") func phrasing() { #expect(BannerCenter.exportOmissionsMessage( InterchangeOmissions(comments: 12, attachments: 0), format: .csv ) == "Exported without 12 comments — CSV can't carry them") #expect(BannerCenter.exportOmissionsMessage( InterchangeOmissions(comments: 0, attachments: 1), format: .markdownOutline ) == "Exported without 1 attachment — Markdown outline can't carry them") #expect(BannerCenter.exportOmissionsMessage( InterchangeOmissions(comments: 1, attachments: 3), format: .obsidianKanban ) == "Exported without 1 comment and 3 attachments — Obsidian Kanban Markdown carries neither") } } // MARK: - Export file naming @Suite("Export naming") struct BoardExporterNamingTests { @Test("The suggested name is the board's, sanitized, with the format's extension") func suggestedFileName() { #expect(BoardExporter.suggestedFileName(boardTitle: "Roadmap", format: .csv) == "Roadmap.csv") #expect(BoardExporter.suggestedFileName(boardTitle: "Roadmap", format: .obsidianKanban) == "Roadmap.md") #expect(BoardExporter.suggestedFileName(boardTitle: "Q3: ship/slip", format: .markdownOutline) == "Q3- ship-slip.md") #expect(BoardExporter.suggestedFileName(boardTitle: " ", format: .csv) == "Board.csv") } } // MARK: - Menu validation @Suite("Export menu validation") struct ExportMenuValidationTests { @Test("Export needs a board window and no open inline editor — Share…'s own rule") func enablement() { #expect(ExportBoardMenu.isEnabled(hasStore: true, hasRef: true, isEditingInline: false)) #expect(!ExportBoardMenu.isEnabled(hasStore: false, hasRef: true, isEditingInline: false)) #expect(!ExportBoardMenu.isEnabled(hasStore: true, hasRef: false, isEditingInline: false)) #expect(!ExportBoardMenu.isEnabled(hasStore: true, hasRef: true, isEditingInline: true)) } }