diff --git a/src/flowchart/flowchart-store.js b/src/flowchart/flowchart-store.js new file mode 100644 index 0000000..d78081b --- /dev/null +++ b/src/flowchart/flowchart-store.js @@ -0,0 +1,261 @@ +/** + * Pure graph store for the flow chart editor. + * + * Graph = { nodes: Node[], edges: Edge[] } + * Node: { id, kind, x, y, label } + * Edge: { id, fromNodeId, toNodeId, kind: 'solid'|'dotted'|'thick', label? } + * + * IO is injected for unit tests + persistence: + * { persistencePath, readFile, writeFile, now } + * + * @module flowchart-store + */ + +const NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document']; +const EDGE_KINDS = ['solid', 'dotted', 'thick']; +const UNDO_LIMIT = 50; + +function clone(obj) { + return JSON.parse(JSON.stringify(obj)); +} + +function newId(prefix) { + // 12 hex chars; monotonic enough for in-memory use. + return `${prefix}_${Math.random().toString(16).slice(2, 10)}${Date.now().toString(16).slice(-4)}`; +} + +function isValidNode(node) { + return ( + node && + typeof node.id === 'string' && + NODE_KINDS.includes(node.kind) && + Number.isFinite(node.x) && + Number.isFinite(node.y) && + typeof node.label === 'string' + ); +} + +function isValidEdge(edge) { + return ( + edge && + typeof edge.id === 'string' && + typeof edge.fromNodeId === 'string' && + typeof edge.toNodeId === 'string' && + EDGE_KINDS.includes(edge.kind) + ); +} + +/** + * @param {object} io + * @param {string} io.persistencePath Absolute path for auto-save JSON. + * @param {(path:string) => Promise} io.readFile + * @param {(path:string, content:string) => Promise} io.writeFile + * @param {() => number} io.now + */ +function create(io) { + if (!io || typeof io !== 'object') { + throw new Error('flowchart-store: io bundle is required'); + } + + let graph = { nodes: [], edges: [] }; + const undoStack = []; + const redoStack = []; + const listeners = new Set(); + + function emit() { + for (const fn of listeners) { + try { + fn(getGraph()); + } catch { + // Don't let a subscriber crash the store. + } + } + } + + function snapshot() { + undoStack.push(clone(graph)); + if (undoStack.length > UNDO_LIMIT) undoStack.shift(); + redoStack.length = 0; + } + + function getGraph() { + return clone(graph); + } + + function addNode({ kind, x, y, label = '' }) { + if (!NODE_KINDS.includes(kind)) { + throw new Error(`flowchart-store: unknown node kind "${kind}"`); + } + snapshot(); + const node = { id: newId('n'), kind, x, y, label }; + graph.nodes.push(node); + emit(); + return node; + } + + function findNodeIndex(id) { + return graph.nodes.findIndex((n) => n.id === id); + } + + function findEdgeIndex(id) { + return graph.edges.findIndex((e) => e.id === id); + } + + function moveNode(id, x, y) { + const idx = findNodeIndex(id); + if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`); + snapshot(); + graph.nodes[idx] = { ...graph.nodes[idx], x, y }; + emit(); + } + + function setNodeLabel(id, label) { + const idx = findNodeIndex(id); + if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`); + snapshot(); + graph.nodes[idx] = { ...graph.nodes[idx], label }; + emit(); + } + + function setNodeKind(id, kind) { + if (!NODE_KINDS.includes(kind)) { + throw new Error(`flowchart-store: unknown node kind "${kind}"`); + } + const idx = findNodeIndex(id); + if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`); + snapshot(); + graph.nodes[idx] = { ...graph.nodes[idx], kind }; + emit(); + } + + function removeNode(id) { + const idx = findNodeIndex(id); + if (idx === -1) return; + snapshot(); + graph.nodes.splice(idx, 1); + graph.edges = graph.edges.filter((e) => e.fromNodeId !== id && e.toNodeId !== id); + emit(); + } + + function connect(fromNodeId, toNodeId, kind = 'solid') { + if (fromNodeId === toNodeId) { + throw new TypeError('flowchart-store: cannot connect a node to itself'); + } + if (!EDGE_KINDS.includes(kind)) { + throw new Error(`flowchart-store: unknown edge kind "${kind}"`); + } + snapshot(); + const edge = { id: newId('e'), fromNodeId, toNodeId, kind }; + graph.edges.push(edge); + emit(); + return edge; + } + + function disconnect(edgeId) { + const idx = findEdgeIndex(edgeId); + if (idx === -1) return; + snapshot(); + graph.edges.splice(idx, 1); + emit(); + } + + function setEdgeKind(edgeId, kind) { + if (!EDGE_KINDS.includes(kind)) { + throw new Error(`flowchart-store: unknown edge kind "${kind}"`); + } + const idx = findEdgeIndex(edgeId); + if (idx === -1) throw new Error(`flowchart-store: unknown edge id "${edgeId}"`); + snapshot(); + graph.edges[idx] = { ...graph.edges[idx], kind }; + emit(); + } + + function setEdgeLabel(edgeId, label) { + const idx = findEdgeIndex(edgeId); + if (idx === -1) throw new Error(`flowchart-store: unknown edge id "${edgeId}"`); + snapshot(); + graph.edges[idx] = { ...graph.edges[idx], label }; + emit(); + } + + function undo() { + const prior = undoStack.pop(); + if (!prior) return; + redoStack.push(clone(graph)); + graph = prior; + emit(); + } + + function redo() { + const next = redoStack.pop(); + if (!next) return; + undoStack.push(clone(graph)); + graph = next; + emit(); + } + + function canUndo() { + return undoStack.length > 0; + } + + function canRedo() { + return redoStack.length > 0; + } + + function subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + } + + function serialize() { + return JSON.stringify(graph); + } + + function deserialize(json) { + let parsed; + try { + parsed = typeof json === 'object' ? json : JSON.parse(json); + } catch { + graph = { nodes: [], edges: [] }; + return; + } + const nodes = Array.isArray(parsed.nodes) ? parsed.nodes.filter(isValidNode) : []; + const nodeIds = new Set(nodes.map((n) => n.id)); + const edges = Array.isArray(parsed.edges) + ? parsed.edges.filter( + (e) => isValidEdge(e) && nodeIds.has(e.fromNodeId) && nodeIds.has(e.toNodeId) + ) + : []; + graph = { nodes, edges }; + undoStack.length = 0; + redoStack.length = 0; + emit(); + } + + function toJSON() { + return clone(graph); + } + + return { + getGraph, + addNode, + moveNode, + setNodeLabel, + setNodeKind, + removeNode, + connect, + disconnect, + setEdgeKind, + setEdgeLabel, + undo, + redo, + canUndo, + canRedo, + subscribe, + serialize, + deserialize, + toJSON, + }; +} + +module.exports = { create, NODE_KINDS, EDGE_KINDS }; diff --git a/tests/flowchart-store.test.js b/tests/flowchart-store.test.js new file mode 100644 index 0000000..82c1c36 --- /dev/null +++ b/tests/flowchart-store.test.js @@ -0,0 +1,242 @@ +/** + * @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'); + }); + + 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()); + }); + + 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'); + }); +});