import assert from "node:assert/strict"; import test from "node:test"; import { visibleWidth } from "@earendil-works/pi-tui"; import { grokHtmlToPlainLines, loadGrokRenderer, parseGrokHtmlLine } from "../src/grok-renderer.ts"; const diagramFamilies = [ { name: "flowchart", source: "flowchart LR\n Start[Start] --> End[End]", labels: ["Start", "End"], }, { name: "sequence", source: "sequenceDiagram\n Browser->>Server: Request\n Server-->>Browser: Response", labels: ["Browser", "Server", "Request", "Response"], }, { name: "state", source: "stateDiagram-v2\n Idle --> Running: start\n Running --> Idle: stop", labels: ["Idle", "Running", "start", "stop"], }, { name: "class", source: "classDiagram\n class User\n User : +String name\n User : +login()", labels: ["User", "name", "login"], }, { name: "entity relationship", source: 'erDiagram\n USER ||--o{ POST : writes\n USER {\n string name\n }', labels: ["USER", "POST", "writes", "name"], }, ] as const; test("renders a flowchart through the vendored Grok WASM", async () => { const renderer = await loadGrokRenderer(); const html = renderer.renderHtml("graph LR\n A[Start] --> B[End]", 80); const plain = grokHtmlToPlainLines(html).join("\n"); assert.match(html, /class="[bn]"/); assert.match(plain, /Start/); assert.match(plain, /End/); assert.match(plain, /[▶▼]/); }); for (const diagram of diagramFamilies) { test(`renders the ${diagram.name} family`, async () => { const renderer = await loadGrokRenderer(); const plain = grokHtmlToPlainLines(renderer.renderHtml(diagram.source, 120)).join("\n"); for (const label of diagram.labels) assert.match(plain, new RegExp(label)); }); } test("honors a width cap by returning output that fits", async () => { const renderer = await loadGrokRenderer(); const html = renderer.renderHtml("graph LR\n A[Alpha] --> B[Beta] --> C[Gamma]", 24); const lines = grokHtmlToPlainLines(html); assert.ok(lines.length > 0); for (const line of lines) assert.ok(visibleWidth(line) <= 24, `${visibleWidth(line)}: ${line}`); }); test("keeps source fallbacks within very narrow width caps", async () => { const renderer = await loadGrokRenderer(); for (const width of [1, 4, 12]) { const lines = grokHtmlToPlainLines(renderer.renderHtml("flowchart LR\n Alpha --> Beta --> Gamma", width)); assert.ok(lines.length > 0); for (const line of lines) { assert.ok(visibleWidth(line) <= width, `${visibleWidth(line)} > ${width}: ${line}`); } } }); test("decodes the renderer's controlled HTML spans", () => { assert.deepEqual(parseGrokHtmlLine('a & b'), [ { classes: ["n", "i"], text: "a & b" }, ]); }); test("uses the upstream framed-source fallback for unsupported diagram families", async () => { const renderer = await loadGrokRenderer(); const plain = grokHtmlToPlainLines(renderer.renderHtml('pie title Pets\n "Dogs" : 3', 80)).join("\n"); assert.match(plain, /mermaid: pie/i); assert.match(plain, /Dogs/); }); test("preserves code-change classes through the bundled WASM", async () => { const renderer = await loadGrokRenderer(); const html = renderer.renderHtml( "flowchart TD\n A[Same]:::same --> B[Added]:::added\n B -.-> C[Removed]:::removed\n D[Changed]\n class D changed\n linkStyle 1 stroke:#cf222e,stroke-dasharray:5", 120, ); for (const semanticClass of ["same", "added", "removed", "changed"]) { assert.match(html, new RegExp(`class=\"[bn] ${semanticClass}\"`)); } assert.match(html, /class="e removed"/); const stateHtml = renderer.renderHtml( "stateDiagram-v2\n Idle:::same --> Running:::added\n Failed\n class Failed removed", 120, ); assert.match(stateHtml, /class="b same"/); assert.match(stateHtml, /class="b added"/); assert.match(stateHtml, /class="b removed"/); }); test("renders a C4-style component view through flowchart conventions", async () => { const renderer = await loadGrokRenderer(); const html = renderer.renderHtml( "flowchart TB\n Reviewer[Person: Reviewer]\n API[Container: API — System: Product]:::changed\n Queue[External: Queue]\n Worker[Component: Worker — Container: API — System: Product]:::added\n Reviewer -->|reviews behavior| API\n API -->|publishes Job| Queue\n Queue -->|delivers Job| Worker", 120, ); const plain = grokHtmlToPlainLines(html).join("\n"); for (const label of ["Person: Reviewer", "System:", "Product", "Container: API", "Component: Worker", "External: Queue"]) { assert.match(plain, new RegExp(label)); } assert.match( plain, /Person: Reviewer[\s\S]*▼reviews behavior[\s\S]*Container: API[\s\S]*▼publishes Job[\s\S]*External: Queue[\s\S]*▼delivers Job[\s\S]*Component: Worker/, ); assert.doesNotMatch(plain, /mermaid: flowchart/i); }); test("uses a generic class's base name for later relationships", async () => { const renderer = await loadGrokRenderer(); const plain = grokHtmlToPlainLines( renderer.renderHtml( "classDiagram\n class Dog~T~ {\n +fetch(item: T) bool\n }\n Dog~U~ : +String name\n Owner --> Dog~U~ : owns", 120, ), ).join("\n"); assert.equal(plain.match(/Dog/g)?.length, 1, plain); assert.doesNotMatch(plain, /Dog/); assert.doesNotMatch(plain, /│ Dog │/); assert.match(plain, /\+String name/); assert.match(plain, /owns/); }); test("keeps generic labels when references create class nodes", async () => { const renderer = await loadGrokRenderer(); const relation = grokHtmlToPlainLines(renderer.renderHtml("classDiagram\n Dog~T~ --> Owner", 120)).join("\n"); assert.match(relation, /Dog/); const annotation = grokHtmlToPlainLines( renderer.renderHtml("classDiagram\n <> Shape~T~", 120), ).join("\n"); assert.match(annotation, /Shape/); assert.match(annotation, /«interface»/); }); test("falls back for malformed generic class identities", async () => { const renderer = await loadGrokRenderer(); for (const source of [ "classDiagram\n class ~T~", "classDiagram\n class Dog~~", "classDiagram\n class Dog~T~x~U~", ]) { const plain = grokHtmlToPlainLines(renderer.renderHtml(source, 120)).join("\n"); assert.match(plain, /mermaid: classDiagram/, source); } }); test("renders quoted ER aliases that declare attributes", async () => { const renderer = await loadGrokRenderer(); const plain = grokHtmlToPlainLines( renderer.renderHtml( 'erDiagram\n p[Person] {\n string name\n }\n a["Customer Account"] {\n string email\n }\n p ||--o| a : has', 120, ), ).join("\n"); assert.doesNotMatch(plain, /mermaid: erDiagram/); for (const label of ["Person", "Customer Account", "string email", "1 has 0..1"]) { assert.match(plain, new RegExp(label)); } }); test("falls back for malformed ER entity tokens", async () => { const renderer = await loadGrokRenderer(); for (const source of [ "erDiagram\n a [Account] {\n string value\n }", "erDiagram\n a[foo] trailing] {\n string value\n }", "erDiagram\n a[foo][bar] {\n string value\n }", 'erDiagram\n a["foo" bar] {\n string value\n }', 'erDiagram\n a[foo "bar"] {\n string value\n }', "erDiagram\n a[foo][bar] ||--|| b : x", "erDiagram\n a] ||--|| b : x", ]) { const plain = grokHtmlToPlainLines(renderer.renderHtml(source, 120)).join("\n"); assert.match(plain, /mermaid: erDiagram/, source); } });