sbuffkin_hexmaker/tests/randomTableExport.test.ts
isaprettycoolguy@protonmail.com 6aa885b5b2 feat: export module — PDF/Markdown for notes, hexes, maps, tables, workflows (1.2.0)
Adds a unified export pipeline under src/export/ with exporters for single notes,
structured hexes, maps (with reference table), random tables, and workflows
(with rolled samples). PDF rendering reuses Obsidian's MarkdownRenderer + theme
CSS; map PNG/PDF goes through a dedicated canvas renderer.

UI surface:
- "Export" tab in MapModal (PNG/PDF with overlay + size options)
- "Export" link in HexEditorModal + HexExportModal
- "Export PDF/Markdown" links in RandomTableView header
- "Export…" button in WorkflowEditorModal + WorkflowExportModal
- Commands: export current note (PDF/MD), current hex, current workflow
- File-menu items on any .md file: Export to PDF / Markdown

Other:
- New `exportFolder` setting (defaults to {worldFolder}/exports)
- hexGeometry: add hexSize / hexCenter / hexPolygonPoints / gridBoundingBox
- HexTableView + HexEditorModal: first-wins on duplicate palette names
  (matches HexMapView's .find() behaviour)
- Tests: unit + integration suites for all exporters; sim-vault fixture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 20:46:41 -04:00

249 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it } from "node:test";
import expect from "expect";
import {
formatRandomTableMarkdown,
formatLinkedNotesAppendix,
} from "../src/export/exporters/randomTable";
import { stripFrontmatter } from "../src/export/mdSerializer";
import type { RandomTable } from "../src/random-tables/randomTable";
const makeTable = (overrides: Partial<RandomTable> = {}): RandomTable => ({
dice: 0,
entries: [],
...overrides,
});
describe("formatRandomTableMarkdown", () => {
it("renders the table name as an h1", () => {
const md = formatRandomTableMarkdown(
"Forest Encounters",
makeTable({ entries: [{ result: "Wolf", weight: 1 }] }),
);
expect(md.startsWith("# Forest Encounters\n")).toBe(true);
});
it("omits the dice line when dice = 0", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({ entries: [{ result: "A", weight: 1 }] }),
);
expect(md).not.toMatch(/Roll d/);
});
it("includes a 'Roll dN' line when dice > 0", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({ dice: 20, entries: [{ result: "A", weight: 1 }] }),
);
expect(md).toMatch(/\*Roll d20\*/);
});
it("emits the description when present", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({
entries: [{ result: "A", weight: 1 }],
description: "Some flavour text.",
}),
);
expect(md).toMatch(/Some flavour text\./);
});
it("uses '#' header column when dice = 0", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({ entries: [{ result: "A", weight: 1 }] }),
);
expect(md).toMatch(/\| # \| Result \|/);
});
it("uses 'Roll' header column with die ranges when dice > 0", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({
dice: 10,
entries: [
{ result: "A", weight: 4 },
{ result: "B", weight: 6 },
],
}),
);
expect(md).toMatch(/\| Roll \| Result \| Weight \|/);
// 10 faces, 4:6 split → 14 and 510
expect(md).toMatch(/\| 14 \| A \| 4 \|/);
expect(md).toMatch(/\| 510 \| B \| 6 \|/);
});
it("omits the Weight column when all weights are 1", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({
entries: [
{ result: "A", weight: 1 },
{ result: "B", weight: 1 },
],
}),
);
expect(md).not.toMatch(/Weight/);
expect(md).toMatch(/\| # \| Result \|/);
});
it("includes the Weight column when any weight is non-1", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({
entries: [
{ result: "A", weight: 1 },
{ result: "B", weight: 3 },
],
}),
);
expect(md).toMatch(/\| # \| Result \| Weight \|/);
expect(md).toMatch(/\| 1 \| A \| 1 \|/);
expect(md).toMatch(/\| 2 \| B \| 3 \|/);
});
it("omits the Odds column entirely", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({ entries: [{ result: "A", weight: 1 }] }),
);
expect(md).not.toMatch(/Odds/);
expect(md).not.toMatch(/%/);
});
it("escapes pipes inside entry results", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({
entries: [{ result: "left | right", weight: 1 }],
}),
);
expect(md).toMatch(/left \\\| right/);
});
it("uses row index as # column when dice = 0", () => {
const md = formatRandomTableMarkdown(
"T",
makeTable({
entries: [
{ result: "A", weight: 1 },
{ result: "B", weight: 1 },
{ result: "C", weight: 1 },
],
}),
);
expect(md).toMatch(/\| 1 \| A \|/);
expect(md).toMatch(/\| 2 \| B \|/);
expect(md).toMatch(/\| 3 \| C \|/);
});
it("renders empty-entries table without crashing", () => {
const md = formatRandomTableMarkdown("Empty", makeTable({ entries: [] }));
expect(md).toMatch(/# Empty/);
// Table header still present
expect(md).toMatch(/\| # \| Result \|/);
});
});
describe("stripFrontmatter", () => {
it("returns content unchanged when no frontmatter is present", () => {
expect(stripFrontmatter("Hello\nworld")).toBe("Hello\nworld");
});
it("strips a basic YAML block", () => {
const md = "---\ntitle: Foo\ntags: [a, b]\n---\n\n# Body";
expect(stripFrontmatter(md)).toBe("# Body");
});
it("strips with no trailing blank line", () => {
const md = "---\ntitle: Foo\n---\n# Body";
expect(stripFrontmatter(md)).toBe("# Body");
});
it("strips with CRLF line endings", () => {
const md = "---\r\ntitle: Foo\r\n---\r\n\r\n# Body";
expect(stripFrontmatter(md)).toBe("# Body");
});
it("does not strip a horizontal rule that isn't frontmatter", () => {
const md = "Some text\n\n---\n\nMore text";
expect(stripFrontmatter(md)).toBe(md);
});
it("returns content unchanged when frontmatter open is unterminated", () => {
const md = "---\ntitle: Foo\nno close\n";
expect(stripFrontmatter(md)).toBe(md);
});
it("handles empty frontmatter block", () => {
const md = "---\n---\n\n# Body";
expect(stripFrontmatter(md)).toBe("# Body");
});
});
describe("formatLinkedNotesAppendix", () => {
it("returns empty string for empty input", () => {
expect(formatLinkedNotesAppendix([])).toBe("");
});
it("wraps a single note in a section with the first-entry class", () => {
const out = formatLinkedNotesAppendix([
{ name: "Goblin Lair", content: "Dark cave entrance." },
]);
expect(out).toMatch(
/<section class="duckmage-export-note duckmage-export-note-first">/,
);
expect(out).toMatch(/## Goblin Lair/);
expect(out).toMatch(/Dark cave entrance\./);
expect(out).toMatch(/<\/section>/);
});
it("only the first section gets the duckmage-export-note-first class", () => {
const out = formatLinkedNotesAppendix([
{ name: "First", content: "One." },
{ name: "Second", content: "Two." },
{ name: "Third", content: "Three." },
]);
const firstClassMatches = out.match(/duckmage-export-note-first/g) ?? [];
expect(firstClassMatches.length).toBe(1);
});
it("wraps each entry in its own section block", () => {
const out = formatLinkedNotesAppendix([
{ name: "First", content: "One." },
{ name: "Second", content: "Two." },
{ name: "Third", content: "Three." },
]);
const openTags = out.match(/<section class="duckmage-export-note/g) ?? [];
const closeTags = out.match(/<\/section>/g) ?? [];
expect(openTags.length).toBe(3);
expect(closeTags.length).toBe(3);
expect(out).toMatch(/## First/);
expect(out).toMatch(/## Second/);
expect(out).toMatch(/## Third/);
});
it("preserves entry order", () => {
const out = formatLinkedNotesAppendix([
{ name: "Beta", content: "B" },
{ name: "Alpha", content: "A" },
]);
expect(out.indexOf("Beta")).toBeLessThan(out.indexOf("Alpha"));
});
it("trims whitespace from note content", () => {
const out = formatLinkedNotesAppendix([
{ name: "T", content: "\n\n body \n\n" },
]);
expect(out).toMatch(/## T\n\nbody/);
});
it("emits no horizontal-rule separators (visual separation is via CSS)", () => {
const out = formatLinkedNotesAppendix([
{ name: "A", content: "alpha" },
{ name: "B", content: "beta" },
]);
expect(out).not.toMatch(/^---$/m);
});
});