mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(flowchart): pure graph store with undo/redo + injectable IO
Amit Haridas
This commit is contained in:
@@ -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<string|null>} io.readFile
|
||||
* @param {(path:string, content:string) => Promise<void>} 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 };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user