Files
lanework/KanbanTests/InterchangeTests.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

691 lines
25 KiB
Swift

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