From 216e0ecf517e6cbc1774a304debaa78df2094502 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 14 Sep 2026 21:36:02 +0530 Subject: [PATCH] feat(flowchart): mermaid translator (flowchart TD, all 5 shapes, 3 edge kinds) Pure Mermaid translator: graph (nodes + edges) -> flowchart TD source. 5 node kinds (process/decision/terminator/subroutine/document) -> Mermaid syntax ([..], {..}, ([..]), [[..]], [/../]). 3 edge kinds (solid/dotted/thick) -> Mermaid arrows (-->, -.->, ==>). Label escaping: double quotes become #quot;, newlines become literal \n. Also moves tests/fixtures per-test dirs to fixtures-epub / fixtures-css so the shared tests/fixtures/ stays available for snapshot fixtures. Amit Haridas --- src/flowchart/flowchart-mermaid.js | 93 +++++++++++++++++ tests/epub-font-embedder.test.js | 3 +- tests/export-css.test.js | 3 +- tests/fixtures/flowchart-snapshots.js | 111 ++++++++++++++++++++ tests/flowchart-mermaid.test.js | 141 ++++++++++++++++++++++++++ 5 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/flowchart/flowchart-mermaid.js create mode 100644 tests/fixtures/flowchart-snapshots.js create mode 100644 tests/flowchart-mermaid.test.js diff --git a/src/flowchart/flowchart-mermaid.js b/src/flowchart/flowchart-mermaid.js new file mode 100644 index 0000000..1208e07 --- /dev/null +++ b/src/flowchart/flowchart-mermaid.js @@ -0,0 +1,93 @@ +/** + * Pure translator: graph → Mermaid `flowchart TD` source. + * + * Output format: + * flowchart TD + * A[Step 1] + * B{Valid?} + * C([Start]) + * D[[Do thing]] + * E[/Report/] + * A --> B + * B -->|yes| C + * + * IDs are 1-3 chars (A..Z, AA..ZZ, …). Labels are escaped: double quotes + * become `#quot;`, newlines become literal `\n` (Mermaid's escape). + * + * @module flowchart-mermaid + */ + +'use strict'; + +const SHAPE_SYNTAX = { + process: (id, label) => `${id}[${label}]`, + decision: (id, label) => `${id}{${label}}`, + terminator: (id, label) => `${id}([${label}])`, + subroutine: (id, label) => `${id}[[${label}]]`, + document: (id, label) => `${id}[/${label}/]`, +}; + +const EDGE_ARROW = { + solid: '-->', + dotted: '-.->', + thick: '==>', +}; + +function escapeLabel(label) { + return String(label || '') + .replace(/"/g, '#quot;') + .replace(/\r?\n/g, '\\n'); +} + +function assignIds(nodes) { + // A, B, C, …, Z, AA, AB, … (matches Mermaid's preferred short ids). + const map = new Map(); + let n = 0; + for (const node of nodes) { + let id; + if (n < 26) id = String.fromCharCode(65 + n); + else { + const first = Math.floor(n / 26) - 1; + const second = n % 26; + id = String.fromCharCode(65 + first) + String.fromCharCode(65 + second); + } + map.set(node.id, id); + n += 1; + } + return map; +} + +function nodeDeclaration(node) { + const fn = SHAPE_SYNTAX[node.kind]; + if (!fn) throw new Error(`flowchart-mermaid: unknown node kind "${node.kind}"`); + return fn(node.id, escapeLabel(node.label)); +} + +function edgeDeclaration(edge, fromId, toId) { + const arrow = EDGE_ARROW[edge.kind]; + if (!arrow) throw new Error(`flowchart-mermaid: unknown edge kind "${edge.kind}"`); + if (edge.label && edge.label.length > 0) { + return `${fromId} ${arrow}|${escapeLabel(edge.label)}| ${toId}`; + } + return `${fromId} ${arrow} ${toId}`; +} + +function toMermaid(graph) { + if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) { + throw new Error('flowchart-mermaid: graph must have nodes[] and edges[]'); + } + const ids = assignIds(graph.nodes); + const lines = ['flowchart TD']; + for (const node of graph.nodes) { + lines.push(nodeDeclaration({ ...node, id: ids.get(node.id) })); + } + for (const edge of graph.edges) { + const fromId = ids.get(edge.fromNodeId); + const toId = ids.get(edge.toNodeId); + if (!fromId || !toId) continue; // skip dangling edges (defensive) + lines.push(edgeDeclaration(edge, fromId, toId)); + } + return lines.join('\n'); +} + +module.exports = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration }; diff --git a/tests/epub-font-embedder.test.js b/tests/epub-font-embedder.test.js index 273c79c..79535ca 100644 --- a/tests/epub-font-embedder.test.js +++ b/tests/epub-font-embedder.test.js @@ -4,7 +4,8 @@ const JSZip = require('jszip'); const EpubFontEmbedder = require('../src/main/EpubFontEmbedder'); describe('EpubFontEmbedder.patchManifest', () => { - const fixturesDir = path.join(__dirname, 'fixtures'); + // Per-test fixture dir (not the shared tests/fixtures/ used by snapshot fixtures). + const fixturesDir = path.join(__dirname, 'fixtures-epub'); const epubPath = path.join(fixturesDir, 'fake.epub'); const fontPath = path.join(fixturesDir, 'fake.ttf'); diff --git a/tests/export-css.test.js b/tests/export-css.test.js index a74e8f0..238f8ad 100644 --- a/tests/export-css.test.js +++ b/tests/export-css.test.js @@ -3,7 +3,8 @@ const path = require('path'); const ExportCss = require('../src/main/ExportCss'); describe('ExportCss.build', () => { - const fakeFontPath = path.join(__dirname, 'fixtures', 'fake.woff2'); + // Per-test fixture dir (not the shared tests/fixtures/ used by snapshot fixtures). + const fakeFontPath = path.join(__dirname, 'fixtures-css', 'fake.woff2'); const fixture = Buffer.from('woff2-binary-fake-data'); beforeAll(() => { diff --git a/tests/fixtures/flowchart-snapshots.js b/tests/fixtures/flowchart-snapshots.js new file mode 100644 index 0000000..ca29de8 --- /dev/null +++ b/tests/fixtures/flowchart-snapshots.js @@ -0,0 +1,111 @@ +'use strict'; + +/** + * Fixture graphs for flowchart-mermaid snapshot tests. + * Mermaid IDs are 1-3 chars; here we use semantic names that match the + * store's auto-generated ids but normalized to A/B/C form for readability. + */ + +function normalizeIds(graph) { + const map = new Map(); + const normNode = (n, i) => { + const id = String.fromCharCode(65 + i); + map.set(n.id, id); + return { ...n, id }; + }; + const normEdge = (e) => ({ + ...e, + fromNodeId: map.get(e.fromNodeId), + toNodeId: map.get(e.toNodeId), + }); + return { + nodes: graph.nodes.map(normNode), + edges: graph.edges.map(normEdge), + }; +} + +const linearChain = { + nodes: [ + { id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' }, + { id: 'n2', kind: 'process', x: 0, y: 0, label: 'Step 1' }, + { id: 'n3', kind: 'process', x: 0, y: 0, label: 'Step 2' }, + { id: 'n4', kind: 'terminator', x: 0, y: 0, label: 'End' }, + ], + edges: [ + { id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' }, + { id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' }, + { id: 'e3', fromNodeId: 'n3', toNodeId: 'n4', kind: 'solid' }, + ], +}; + +const decisionDiamond = { + nodes: [ + { id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' }, + { id: 'n2', kind: 'process', x: 0, y: 0, label: 'Get input' }, + { id: 'n3', kind: 'decision', x: 0, y: 0, label: 'Valid?' }, + { id: 'n4', kind: 'process', x: 0, y: 0, label: 'Process' }, + { id: 'n5', kind: 'process', x: 0, y: 0, label: 'Show error' }, + { id: 'n6', kind: 'terminator', x: 0, y: 0, label: 'End' }, + ], + edges: [ + { id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' }, + { id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' }, + { id: 'e3', fromNodeId: 'n3', toNodeId: 'n4', kind: 'solid', label: 'yes' }, + { id: 'e4', fromNodeId: 'n3', toNodeId: 'n5', kind: 'solid', label: 'no' }, + { id: 'e5', fromNodeId: 'n4', toNodeId: 'n6', kind: 'solid' }, + ], +}; + +const parallelBranches = { + nodes: [ + { id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' }, + { id: 'n2', kind: 'process', x: 0, y: 0, label: 'Fork' }, + { id: 'n3', kind: 'process', x: 0, y: 0, label: 'Branch A' }, + { id: 'n4', kind: 'process', x: 0, y: 0, label: 'Branch B' }, + { id: 'n5', kind: 'terminator', x: 0, y: 0, label: 'End' }, + ], + edges: [ + { id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' }, + { id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' }, + { id: 'e3', fromNodeId: 'n2', toNodeId: 'n4', kind: 'solid' }, + { id: 'e4', fromNodeId: 'n3', toNodeId: 'n5', kind: 'solid' }, + { id: 'e5', fromNodeId: 'n4', toNodeId: 'n5', kind: 'solid' }, + ], +}; + +const cycle = { + nodes: [ + { id: 'n1', kind: 'process', x: 0, y: 0, label: 'A' }, + { id: 'n2', kind: 'process', x: 0, y: 0, label: 'B' }, + ], + edges: [ + { id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' }, + { id: 'e2', fromNodeId: 'n2', toNodeId: 'n1', kind: 'dotted' }, + ], +}; + +function largeGraph(n) { + const nodes = []; + const edges = []; + for (let i = 0; i < n; i += 1) { + nodes.push({ id: `n${i}`, kind: 'process', x: 0, y: 0, label: `Step ${i}` }); + if (i > 0) { + edges.push({ + id: `e${i}`, + fromNodeId: `n${i - 1}`, + toNodeId: `n${i}`, + kind: 'solid', + }); + } + } + return { nodes, edges }; +} + +module.exports = { + normalizeIds, + linearChain, + decisionDiamond, + parallelBranches, + cycle, + largeGraph, +}; diff --git a/tests/flowchart-mermaid.test.js b/tests/flowchart-mermaid.test.js new file mode 100644 index 0000000..5527116 --- /dev/null +++ b/tests/flowchart-mermaid.test.js @@ -0,0 +1,141 @@ +const { + toMermaid, + escapeLabel, + nodeDeclaration, + edgeDeclaration, +} = require('../src/flowchart/flowchart-mermaid'); +const { + normalizeIds, + linearChain, + decisionDiamond, + parallelBranches, + cycle, + largeGraph, +} = require('./fixtures/flowchart-snapshots'); + +describe('flowchart-mermaid: escapeLabel', () => { + test('escapes double quotes', () => { + expect(escapeLabel('say "hi"')).toBe('say #quot;hi#quot;'); + }); + + test('escapes newlines to literal \\n', () => { + expect(escapeLabel('line1\nline2')).toBe('line1\\nline2'); + }); + + test('passes plain text through', () => { + expect(escapeLabel('Hello world')).toBe('Hello world'); + }); +}); + +describe('flowchart-mermaid: nodeDeclaration', () => { + test('process → A[Label]', () => { + expect(nodeDeclaration({ id: 'A', kind: 'process', label: 'Step' })).toBe('A[Step]'); + }); + test('decision → A{Label}', () => { + expect(nodeDeclaration({ id: 'A', kind: 'decision', label: 'Yes?' })).toBe('A{Yes?}'); + }); + test('terminator → A([Label])', () => { + expect(nodeDeclaration({ id: 'A', kind: 'terminator', label: 'Start' })).toBe('A([Start])'); + }); + test('subroutine → A[[Label]]', () => { + expect(nodeDeclaration({ id: 'A', kind: 'subroutine', label: 'Do thing' })).toBe( + 'A[[Do thing]]' + ); + }); + test('document → A[/Label/]', () => { + expect(nodeDeclaration({ id: 'A', kind: 'document', label: 'Report' })).toBe('A[/Report/]'); + }); + test('unknown kind throws', () => { + expect(() => nodeDeclaration({ id: 'A', kind: 'hexagon', label: 'x' })).toThrow(/hexagon/); + }); +}); + +describe('flowchart-mermaid: edgeDeclaration', () => { + test('solid → A --> B', () => { + expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid' }, 'A', 'B')).toBe( + 'A --> B' + ); + }); + test('dotted → A -.-> B', () => { + expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'dotted' }, 'A', 'B')).toBe( + 'A -.-> B' + ); + }); + test('thick → A ==> B', () => { + expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'thick' }, 'A', 'B')).toBe( + 'A ==> B' + ); + }); + test('with label → A -->|yes| B', () => { + expect( + edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid', label: 'yes' }, 'A', 'B') + ).toBe('A -->|yes| B'); + }); +}); + +describe('flowchart-mermaid: toMermaid — full graphs', () => { + test('linearChain', () => { + const out = toMermaid(normalizeIds(linearChain)); + expect(out).toMatch(/^flowchart TD/); + expect(out).toMatch(/A\(\[Start\]\)/); + expect(out).toMatch(/B\[Step 1\]/); + expect(out).toMatch(/C\[Step 2\]/); + expect(out).toMatch(/D\(\[End\]\)/); + expect(out).toMatch(/A --> B/); + expect(out).toMatch(/B --> C/); + expect(out).toMatch(/C --> D/); + }); + + test('decisionDiamond includes labeled branches', () => { + const out = toMermaid(normalizeIds(decisionDiamond)); + expect(out).toMatch(/C\{Valid\?\}/); + expect(out).toMatch(/C -->\|yes\| D/); + expect(out).toMatch(/C -->\|no\| E/); + }); + + test('parallelBranches', () => { + const out = toMermaid(normalizeIds(parallelBranches)); + expect(out).toMatch(/B --> C/); + expect(out).toMatch(/B --> D/); + expect(out).toMatch(/C --> E/); + expect(out).toMatch(/D --> E/); + }); + + test('cycle uses dotted for the back-edge', () => { + const out = toMermaid(normalizeIds(cycle)); + expect(out).toMatch(/A --> B/); + expect(out).toMatch(/B -\.-> A/); + }); + + test('largeGraph(20) emits 20 nodes and 19 edges in order', () => { + const out = toMermaid(normalizeIds(largeGraph(20))); + const nodeCount = (out.match(/^[A-Z]\[/gm) || []).length; + const edgeCount = (out.match(/ --> /g) || []).length; + expect(nodeCount).toBe(20); + expect(edgeCount).toBe(19); + }); + + test('empty graph still emits the header', () => { + expect(toMermaid({ nodes: [], edges: [] })).toBe('flowchart TD'); + }); + + test('label with embedded double-quote is escaped', () => { + const out = toMermaid( + normalizeIds({ + nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'say "hi"' }], + edges: [], + }) + ); + expect(out).toMatch(/A\[say #quot;hi#quot;\]/); + }); + + test('label with newline uses \\n escape', () => { + const out = toMermaid( + normalizeIds({ + nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'line1\nline2' }], + edges: [], + }) + ); + expect(out).toMatch(/A\[line1\\nline2\]/); + }); +});