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