mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
250 lines
7.4 KiB
TypeScript
250 lines
7.4 KiB
TypeScript
|
|
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 → 1–4 and 5–10
|
|||
|
|
expect(md).toMatch(/\| 1–4 \| A \| 4 \|/);
|
|||
|
|
expect(md).toMatch(/\| 5–10 \| 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);
|
|||
|
|
});
|
|||
|
|
});
|