mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
v4.13.0 — nodes can be resized by dragging the bottom-right handle.
- New setNodeWidth(id, width) on the store. Clamps to [60, 600] so
shapes stay readable. Throws on unknown id; pushes an undo
snapshot like every other mutator.
- New nodeWidth(node) helper on the canvas uses node.width with a
fallback to DEFAULT_WIDTH for graphs loaded from older JSON
that didn't have the field.
- Canvas render uses the per-node width for shapeSvg + label
positioning, and edge boundaryPoint() uses it too so connection
geometry adapts to the new shape size.
- Selected node renders a bottom-right resize handle (10px square).
Clicking the handle starts a 'resize' dragState that calls
setNodeWidth on every pointermove with the screen-to-SVG delta
applied to the start width.
- The handle is detected before the generic node click handler so
it doesn't accidentally start a move drag.
3 new tests cover setNodeWidth update, clamp to [60, 600], and
unknown-id throw. The canvas change reuses the existing 14 tests
(which check rendering and pointer events) — they still pass.
Amit Haridas
338 lines
13 KiB
JavaScript
338 lines
13 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('setNodeWidth updates the width of an existing node', () => {
|
|
const store = create(makeIO());
|
|
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
|
|
store.setNodeWidth(node.id, 220);
|
|
expect(store.getGraph().nodes[0].width).toBe(220);
|
|
});
|
|
|
|
test('setNodeWidth clamps to [60, 600]', () => {
|
|
const store = create(makeIO());
|
|
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
|
|
store.setNodeWidth(node.id, 10);
|
|
expect(store.getGraph().nodes[0].width).toBe(60);
|
|
store.setNodeWidth(node.id, 99999);
|
|
expect(store.getGraph().nodes[0].width).toBe(600);
|
|
});
|
|
|
|
test('setNodeWidth throws on unknown node id', () => {
|
|
const store = create(makeIO());
|
|
expect(() => store.setNodeWidth('nope', 200)).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');
|
|
});
|
|
});
|