import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; import { parseMarkdown } from "../../markdown/index.js"; import { runCheck } from "./index.js"; import { formatHuman, type CheckReport } from "./diagnostics.js"; // `marked` ships ESM only, which jest's CJS runtime cannot load. The markdown // pipeline is mocked here; its real behavior is covered outside jest. jest.mock("../../markdown/index.js", () => ({ parseMarkdown: jest.fn((content: string) => content), })); const dirs: string[] = []; function fixture(files: Record): string { const dir = mkdtempSync(join(tmpdir(), "tttk-check-")); dirs.push(dir); for (const [rel, content] of Object.entries(files)) { const full = join(dir, rel); mkdirSync(dirname(full), { recursive: true }); writeFileSync(full, content, "utf-8"); } return dir; } function codes(report: CheckReport): string[] { return report.diagnostics.map((d) => d.code); } afterEach(() => { jest.restoreAllMocks(); while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); }); beforeEach(() => { // The registry legitimately warns about unknown roles; keep test output clean. jest.spyOn(console, "warn").mockImplementation(() => {}); jest.spyOn(console, "log").mockImplementation(() => {}); (parseMarkdown as jest.Mock).mockImplementation((content: string) => content); }); describe("runCheck", () => { test("a clean multi-file document produces no diagnostics", () => { const dir = fixture({ "adventure.md": "# Adventure\n\n:md-dice[2d6]{key=\"dmg\"}\n\n:md-table[./npcs.csv]\n", "npcs.csv": "label,body\n1,Alice\n", }); const report = runCheck(dir); expect(report.diagnostics).toEqual([]); expect(report.filesChecked).toBe(1); }); test("R001 flags an unknown code-block role", () => { const dir = fixture({ "doc.md": "```csv role=spark-tabel\nd6,Name\n1,Alice\n```\n", }); const report = runCheck(dir); expect(codes(report)).toContain("TTTK-R001"); expect(report.diagnostics[0].line).toBe(1); }); test("R003 flags a declare block missing key/expr columns", () => { const dir = fixture({ "doc.md": "```csv role=declare\nfoo,bar\n1,2\n```\n", }); expect(codes(runCheck(dir))).toContain("TTTK-R003"); }); test("R005 flags invalid YAML in a role=tag block", () => { const dir = fixture({ "doc.md": "```yaml role=tag\ntag: [unclosed\n```\n", }); expect(codes(runCheck(dir))).toContain("TTTK-R005"); }); test("R006 flags a role=tag naming an unknown element", () => { const dir = fixture({ "doc.md": "```yaml role=tag\ntag: md-nope\n```\n", }); expect(codes(runCheck(dir))).toContain("TTTK-R006"); }); test("S001 reports when the markdown pipeline throws", () => { (parseMarkdown as jest.Mock).mockImplementation(() => { throw new Error("boom"); }); expect(codes(runCheck(fixture({ "doc.md": "# ok\n" })))).toContain( "TTTK-S001", ); }); test("C001 flags an unresolved content reference", () => { const dir = fixture({ "doc.md": "See :md-table[./missing.csv].\n", }); const report = runCheck(dir); const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-C001"); expect(diagnostic).toBeDefined(); expect(diagnostic!.line).toBe(1); }); test("D001 flags an unregistered :md-* element", () => { const dir = fixture({ "doc.md": "A card: :md-card[./cards.csv]\n", }); expect(codes(runCheck(dir))).toContain("TTTK-D001"); }); test("L001 flags a missing link target", () => { const dir = fixture({ "doc.md": "See [rules](./rules.md).\n", }); expect(codes(runCheck(dir))).toContain("TTTK-L001"); }); test("S002 flags an unterminated container directive", () => { const dir = fixture({ "doc.md": "::: box\nsome content\n", }); expect(codes(runCheck(dir))).toContain("TTTK-S002"); }); test("C004 flags an embed cycle", () => { const dir = fixture({ "a.md": ":md-embed[./b.md]\n", "b.md": ":md-embed[./a.md]\n", }); expect(codes(runCheck(dir))).toContain("TTTK-C004"); }); test("D002 flags an unquoted attribute value containing a space", () => { const dir = fixture({ "doc.md": ":md-pins[./map.png]{pins=A:30, 40}\n", }); expect(codes(runCheck(dir))).toContain("TTTK-D002"); }); test("D002 accepts a quoted attribute value containing a space", () => { const dir = fixture({ "doc.md": ':md-pins[./map.png]{pins="A:30, 40"}\n', }); expect(codes(runCheck(dir))).not.toContain("TTTK-D002"); }); test("D002 accepts concatenated class shorthands and boolean flags", () => { const dir = fixture({ "doc.md": ':md-border[./frame.png]{width="2mm" slice="10" .all.round}\n', }); expect(codes(runCheck(dir))).not.toContain("TTTK-D002"); }); test("R004 flags a declare row with an empty key or expr", () => { const dir = fixture({ "doc.md": "```csv role=declare\nkey,expr\n$hp,1\n,2\n```\n", }); const report = runCheck(dir); const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004"); expect(diagnostic).toBeDefined(); expect(diagnostic!.line).toBe(4); }); test("V001 flags a CSV that fails to parse", () => { const dir = fixture({ "doc.md": "See :md-table[./bad.csv].\n", "bad.csv": 'label,body\n1,"unterminated\n', }); const report = runCheck(dir); const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V001"); expect(diagnostic).toBeDefined(); expect(diagnostic!.file).toBe("bad.csv"); }); test("V002 flags a row whose column count differs from the header", () => { const dir = fixture({ "doc.md": "See :md-table[./data.csv].\n", "data.csv": "label,body\n1,a\n2,b,c\n", }); const report = runCheck(dir); const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V002"); expect(diagnostic).toBeDefined(); expect(diagnostic!.line).toBe(3); }); test("V003 flags duplicate label values", () => { const dir = fixture({ "doc.md": "See :md-table[./data.csv].\n", "data.csv": "label,body\n1,a\n1,b\n", }); const report = runCheck(dir); const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V003"); expect(diagnostic).toBeDefined(); expect(diagnostic!.line).toBe(3); }); test("V004 flags an unresolved {{prop}} in the body column", () => { const dir = fixture({ "doc.md": "See :md-table[./data.csv].\n", "data.csv": "label,body\n1,{{missing}}\n", }); expect(codes(runCheck(dir))).toContain("TTTK-V004"); }); test("V004 accepts a {{prop}} naming a column or front-matter key", () => { const dir = fixture({ "doc.md": "See :md-table[./data.csv].\n", "data.csv": "---\nsystem: D&D\n---\nlabel,body,name\n1,{{name}} of {{system}}\n", }); expect(codes(runCheck(dir))).not.toContain("TTTK-V004"); }); test("V005 flags a spark-table roll value that is not a number or range", () => { const dir = fixture({ "doc.md": "```csv role=spark-table\nd6,result\nx,foo\n```\n", }); const report = runCheck(dir); const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V005"); expect(diagnostic).toBeDefined(); expect(diagnostic!.line).toBe(3); }); test("a clean CSV produces no diagnostics", () => { const dir = fixture({ "doc.md": "See :md-table[./data.csv].\n", "data.csv": "label,body\n1,a\n2,b\n", }); expect(runCheck(dir).diagnostics).toEqual([]); }); test("a CSV referenced by two documents is only reported once", () => { const dir = fixture({ "a.md": "See :md-table[./data.csv].\n", "b.md": "See :md-table[./data.csv].\n", "data.csv": "label,body\n1,a\n1,b\n", }); const v003 = runCheck(dir).diagnostics.filter( (d) => d.code === "TTTK-V003", ); expect(v003).toHaveLength(1); }); test("directives inside fenced blocks are not live content", () => { const dir = fixture({ "doc.md": "```\n:md-table[./missing.csv]\n:md-card[x]\n```\n", }); expect(runCheck(dir).diagnostics).toEqual([]); }); test("directives inside inline code are not live content", () => { const dir = fixture({ "doc.md": "Use `:md-table[./missing.csv]` to embed a table.\n", }); expect(runCheck(dir).diagnostics).toEqual([]); }); test("diagnostics are sorted by file, then line", () => { const dir = fixture({ "b.md": "See [x](./missing.md).\n", "a.md": "\n\nSee [y](./missing.md).\n", }); const files = runCheck(dir).diagnostics.map((d) => d.file); expect(files).toEqual([...files].sort()); expect(files[0]).toBe("a.md"); }); }); describe("formatHuman", () => { test("groups by file and prints a summary", () => { const dir = fixture({ "doc.md": "See :md-table[./missing.csv].\n" }); const text = formatHuman(runCheck(dir)); expect(text).toContain("doc.md"); expect(text).toContain("TTTK-C001"); expect(text).toContain("1 error"); expect(text).toContain("1 file checked"); }); });