Files
markdown-converter/tests/flowchart-store.test.js
T
amitwh 23046967c3 feat(flowchart): discoverable connect form + per-node color + save-to-file export; v4.12.0
User feedback on v4.11.0: the Add Connection form was buried below the
node/edge lists and they couldn't find it; they also asked for per-node
color and Save to File export. v4.12.0 reorganises the #fc-nodelist panel
to put the connect form right after the Add Node buttons, adds a native
<input type="color"> per node row that drives a new store.setNodeColor
mutator (with serialize/deserialize round-trip), and wires a Save to File
button that pops a system save dialog via a new 'save-text-file' IPC
channel.

- src/flowchart-generator.html — panel order is now Add Node /
  Add Connection / Nodes / Edges / Export (Insert · Save · Reset).
  Removed the legacy top toolbar; status moved into the panel.
  Added .fc-help, per-node color-picker CSS, .fc-toolbar-row.
- src/flowchart/flowchart-shapes.js — shapeSvg() accepts an optional
  6th 'color' arg; emits fill=... on rects and polygons. Falls back to
  #ffffff for empty/null/undefined so old callers keep working.
- src/flowchart/flowchart-store.js — setNodeColor(id, color) mutator
  with snapshot/emit, normalizeColor() helper (hex validation),
  addNode accepts color, deserialize normalises missing color.
- src/renderer/flowchart-bundle.js — mirrors all of the above inline
  (the bundle is loaded as a single <script>), wires fc-btn-save to
  api.saveFile, passes node.color to shapeSvg in the canvas render,
  and renders <input type='color'> per node row.
- src/main.js — new ipcMain.handle('save-text-file', ...) using a
  system Save dialog (mirrors the ascii:save handler shape).
- src/preload.js — added 'save-text-file' to ALLOWED_SEND_CHANNELS
  and a saveFile(content, defaultName) helper on the flowchart bridge.

Tests: 92/92 suites, 1165/1165 tests pass on this run
- +9 store tests (color defaults, setNodeColor, undo, hex validation,
  round-trip, deserialize normalisation)
- +7 shapes tests (fill color on all 5 kinds, fallback to #ffffff)
- +6 controller tests (color picker wires setNodeColor, canvas rect
  reflects color, Save to File calls api.saveFile with the fenced
  Mermaid source and 'flowchart.mmd', cancel + error paths surface in
  status)

Verification:
- npm run lint          clean
- npm run format:check  clean
- npm test              92/92 suites, 1165/1165 tests pass

Amit Haridas
2026-09-15 09:23:58 +05:30

317 lines
12 KiB
JavaScript

/**
* @jest-environment node
*/
const { create } = require('../src/flowchart/flowchart-store');
function makeIO(overrides = {}) {
return {
persistencePath: '/tmp/flowchart-session.json',
readFile: jest.fn().mockResolvedValue(null),
writeFile: jest.fn().mockResolvedValue(undefined),
now: () => 1700000000000,
...overrides,
};
}
describe('flowchart-store: node operations', () => {
test('addNode creates a node with id, kind, x, y, label and assigns an id', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 10, y: 20, label: 'Hello' });
expect(node).toMatchObject({ kind: 'process', x: 10, y: 20, label: 'Hello' });
expect(typeof node.id).toBe('string');
expect(node.id.length).toBeGreaterThan(0);
expect(store.getGraph().nodes).toContainEqual(node);
});
test('addNode defaults label to empty string when omitted', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'decision', x: 0, y: 0 });
expect(node.label).toBe('');
});
test('addNode throws on unknown kind', () => {
const store = create(makeIO());
expect(() => store.addNode({ kind: 'bogus', x: 0, y: 0 })).toThrow(/bogus/);
});
test('moveNode updates position of existing node', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
store.moveNode(node.id, 50, 60);
const moved = store.getGraph().nodes.find((n) => n.id === node.id);
expect(moved).toMatchObject({ x: 50, y: 60 });
});
test('moveNode throws on unknown node id', () => {
const store = create(makeIO());
expect(() => store.moveNode('does-not-exist', 0, 0)).toThrow(/does-not-exist/);
});
test('setNodeLabel updates label of existing node', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.setNodeLabel(node.id, 'B');
expect(store.getGraph().nodes.find((n) => n.id === node.id).label).toBe('B');
});
test('setNodeKind updates kind of existing node', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
store.setNodeKind(node.id, 'decision');
expect(store.getGraph().nodes.find((n) => n.id === node.id).kind).toBe('decision');
});
// v4.12.0 — per-node fill color. Mirrors setNodeKind/setNodeLabel semantics.
test('addNode defaults color to #ffffff when not provided', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
expect(node.color).toBe('#ffffff');
expect(store.getGraph().nodes[0].color).toBe('#ffffff');
});
test('addNode accepts an explicit color', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '', color: '#ff0000' });
expect(node.color).toBe('#ff0000');
});
test('setNodeColor updates the color of an existing node', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
store.setNodeColor(node.id, '#336699');
expect(store.getGraph().nodes.find((n) => n.id === node.id).color).toBe('#336699');
});
test('setNodeColor accepts hex without leading #', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
store.setNodeColor(node.id, 'abcdef');
expect(store.getGraph().nodes[0].color).toBe('#abcdef');
});
test('setNodeColor falls back to #ffffff for non-hex strings', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
store.setNodeColor(node.id, 'not-a-color');
expect(store.getGraph().nodes[0].color).toBe('#ffffff');
});
test('setNodeColor throws on unknown node id', () => {
const store = create(makeIO());
expect(() => store.setNodeColor('nope', '#ff0000')).toThrow(/nope/);
});
test('setNodeColor pushes an undo snapshot', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
store.setNodeColor(node.id, '#abcdef');
expect(store.getGraph().nodes[0].color).toBe('#abcdef');
store.undo();
expect(store.getGraph().nodes[0].color).toBe('#ffffff');
});
test('removeNode removes the node and any connected edges', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
const edge = store.connect(a.id, b.id, 'solid');
store.removeNode(a.id);
expect(store.getGraph().nodes.find((n) => n.id === a.id)).toBeUndefined();
expect(store.getGraph().edges.find((e) => e.id === edge.id)).toBeUndefined();
});
});
describe('flowchart-store: edge operations', () => {
test('connect creates a solid edge by default', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
const edge = store.connect(a.id, b.id);
expect(edge).toMatchObject({ fromNodeId: a.id, toNodeId: b.id, kind: 'solid' });
expect(store.getGraph().edges).toContainEqual(edge);
});
test('connect accepts solid|dotted|thick', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
expect(store.connect(a.id, b.id, 'dotted').kind).toBe('dotted');
expect(store.connect(a.id, b.id, 'thick').kind).toBe('thick');
});
test('connect throws when from === to', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
expect(() => store.connect(a.id, a.id)).toThrow(TypeError);
});
test('connect throws on unknown edge kind', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
expect(() => store.connect(a.id, b.id, 'wavy')).toThrow(/wavy/);
});
test('disconnect removes the edge', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
const edge = store.connect(a.id, b.id, 'solid');
store.disconnect(edge.id);
expect(store.getGraph().edges.find((e) => e.id === edge.id)).toBeUndefined();
});
test('setEdgeKind and setEdgeLabel mutate the edge', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
const edge = store.connect(a.id, b.id, 'solid');
store.setEdgeKind(edge.id, 'thick');
store.setEdgeLabel(edge.id, 'next');
const updated = store.getGraph().edges.find((e) => e.id === edge.id);
expect(updated).toMatchObject({ kind: 'thick', label: 'next' });
});
});
describe('flowchart-store: subscribe', () => {
test('subscribe fires once per mutation', () => {
const store = create(makeIO());
const fn = jest.fn();
store.subscribe(fn);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.addNode({ kind: 'process', x: 10, y: 10, label: 'B' });
expect(fn).toHaveBeenCalledTimes(2);
});
test('unsubscribe stops further notifications', () => {
const store = create(makeIO());
const fn = jest.fn();
const unsub = store.subscribe(fn);
unsub();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
expect(fn).not.toHaveBeenCalled();
});
});
describe('flowchart-store: undo / redo', () => {
test('undo restores prior state after addNode', () => {
const store = create(makeIO());
expect(store.getGraph().nodes).toHaveLength(0);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
expect(store.getGraph().nodes).toHaveLength(1);
store.undo();
expect(store.getGraph().nodes).toHaveLength(0);
});
test('redo replays the undone mutation', () => {
const store = create(makeIO());
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.undo();
store.redo();
expect(store.getGraph().nodes).toHaveLength(1);
});
test('canUndo and canRedo reflect stack state', () => {
const store = create(makeIO());
expect(store.canUndo()).toBe(false);
expect(store.canRedo()).toBe(false);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
expect(store.canUndo()).toBe(true);
expect(store.canRedo()).toBe(false);
store.undo();
expect(store.canUndo()).toBe(false);
expect(store.canRedo()).toBe(true);
});
test('new mutation after undo drops the redo stack', () => {
const store = create(makeIO());
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.undo();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
expect(store.canRedo()).toBe(false);
});
test('snapshot stack is bounded at depth 50', () => {
const store = create(makeIO());
for (let i = 0; i < 60; i += 1) {
store.addNode({ kind: 'process', x: i, y: 0, label: `n${i}` });
}
let undoCount = 0;
while (store.canUndo()) {
store.undo();
undoCount += 1;
if (undoCount > 100) throw new Error('undo did not terminate');
}
expect(undoCount).toBeLessThanOrEqual(50);
});
});
describe('flowchart-store: serialize / deserialize', () => {
test('serialize → deserialize round-trip preserves graph', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 10, y: 20, label: 'A' });
const b = store.addNode({ kind: 'decision', x: 30, y: 40, label: 'B?' });
store.connect(a.id, b.id, 'thick');
const json = store.serialize();
const restored = create(makeIO());
restored.deserialize(json);
expect(restored.getGraph()).toEqual(store.getGraph());
});
// v4.12.0 — color is part of the persisted graph and survives round-trip.
test('serialize → deserialize round-trip preserves per-node color', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 10, y: 20, label: 'A', color: '#ff0000' });
const b = store.addNode({ kind: 'decision', x: 30, y: 40, label: 'B?', color: '#00aaff' });
const json = store.serialize();
const restored = create(makeIO());
restored.deserialize(json);
const restoredA = restored.getGraph().nodes.find((n) => n.id === a.id);
const restoredB = restored.getGraph().nodes.find((n) => n.id === b.id);
expect(restoredA.color).toBe('#ff0000');
expect(restoredB.color).toBe('#00aaff');
});
test('deserialize normalises missing color to #ffffff', () => {
const store = create(makeIO());
store.deserialize(
JSON.stringify({
nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'A' }],
edges: [],
})
);
expect(store.getGraph().nodes[0].color).toBe('#ffffff');
});
test('deserialize handles corrupt JSON by returning empty graph', () => {
const store = create(makeIO());
expect(() => store.deserialize('{not-json')).not.toThrow();
expect(store.getGraph()).toEqual({ nodes: [], edges: [] });
});
test('deserialize validates node kinds and drops invalid nodes', () => {
const store = create(makeIO());
store.deserialize(
JSON.stringify({
nodes: [
{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'A' },
{ id: 'n2', kind: 'bogus', x: 0, y: 0, label: 'X' },
],
edges: [],
})
);
expect(store.getGraph().nodes).toHaveLength(1);
});
});
describe('flowchart-store: persistence (injected IO)', () => {
test('writeFile is called with serialized graph when subscribing to persistence', async () => {
const io = makeIO();
const store = create(io);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
// Persistence is debounced inside the panel; the store itself does not
// auto-write. Verify only that the IO is wired through.
expect(io.persistencePath).toBe('/tmp/flowchart-session.json');
});
});