import { describe, it, expect } from 'vitest'; import { seedPositions, tick, runToSettle } from './force-simulation'; import type { GraphEdge, GraphNode } from './graph.types'; const indexOf = (nodes: GraphNode[]) => new Map(nodes.map((n, i) => [n.id, i])); describe('seedPositions', () => { it('is deterministic, so a diagram can be talked about', () => { const nodes: GraphNode[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; expect(seedPositions(nodes, '3d')).toEqual(seedPositions(nodes, '3d')); }); it('keeps a node seed stable when its neighbours change', () => { const first = seedPositions([{ id: 'a' }, { id: 'b' }], '3d'); const second = seedPositions([{ id: 'a' }, { id: 'b' }, { id: 'c' }], '3d'); // Seeded from the id, not the index — adding a node must not teleport 'a'. expect(second[0].x / first[0].x).toBeCloseTo(second[0].y / first[0].y, 5); }); it('flattens z in 2d', () => { expect(seedPositions([{ id: 'a' }, { id: 'b' }], '2d').every(s => s.z === 0)).toBe(true); }); it('honours an explicit seed position', () => { const [state] = seedPositions([{ id: 'a', position: { x: 5, y: 6, z: 7 } }], '3d'); expect(state).toMatchObject({ x: 5, y: 6, z: 7 }); }); it('carries pinning and weight through', () => { const [state] = seedPositions([{ id: 'a', pinned: true, weight: 4 }], '3d'); expect(state.pinned).toBe(true); expect(state.mass).toBe(4); }); }); describe('tick', () => { it('reports settled for an empty graph', () => { expect(tick([], [], new Map(), '3d')).toEqual({ movement: 0, settled: true, alpha: 0 }); }); it('pushes unconnected nodes apart', () => { const nodes: GraphNode[] = [ { id: 'a', position: { x: -5, y: 0, z: 0 } }, { id: 'b', position: { x: 5, y: 0, z: 0 } }, ]; const states = seedPositions(nodes, '3d'); const before = Math.abs(states[0].x - states[1].x); tick(states, [], indexOf(nodes), '3d'); expect(Math.abs(states[0].x - states[1].x)).toBeGreaterThan(before); }); it('pulls nodes together along an edge that is stretched', () => { const nodes: GraphNode[] = [ { id: 'a', position: { x: -400, y: 0, z: 0 } }, { id: 'b', position: { x: 400, y: 0, z: 0 } }, ]; const edges: GraphEdge[] = [{ source: 'a', target: 'b' }]; const states = seedPositions(nodes, '3d'); const before = Math.abs(states[0].x - states[1].x); tick(states, edges, indexOf(nodes), '3d'); expect(Math.abs(states[0].x - states[1].x)).toBeLessThan(before); }); it('never moves a pinned node', () => { const nodes: GraphNode[] = [ { id: 'a', position: { x: 0, y: 0, z: 0 }, pinned: true }, { id: 'b', position: { x: 3, y: 0, z: 0 } }, ]; const states = seedPositions(nodes, '3d'); for (let i = 0; i < 20; i += 1) tick(states, [], indexOf(nodes), '3d'); expect(states[0]).toMatchObject({ x: 0, y: 0, z: 0 }); }); it('keeps the layout flat in 2d', () => { const nodes: GraphNode[] = [ { id: 'a', position: { x: -5, y: 2, z: 0 } }, { id: 'b', position: { x: 5, y: -2, z: 0 } }, ]; const states = seedPositions(nodes, '2d'); for (let i = 0; i < 30; i += 1) tick(states, [], indexOf(nodes), '2d'); expect(states.every(s => s.z === 0 && s.vz === 0)).toBe(true); }); it('ignores edges pointing at nodes that do not exist', () => { const nodes: GraphNode[] = [{ id: 'a' }]; const states = seedPositions(nodes, '3d'); expect(() => tick(states, [{ source: 'a', target: 'ghost' }], indexOf(nodes), '3d') ).not.toThrow(); expect(Number.isFinite(states[0].x)).toBe(true); }); it('ignores a self-loop rather than producing NaN', () => { const nodes: GraphNode[] = [{ id: 'a', position: { x: 1, y: 1, z: 1 } }]; const states = seedPositions(nodes, '3d'); tick(states, [{ source: 'a', target: 'a' }], indexOf(nodes), '3d'); expect(Number.isFinite(states[0].x)).toBe(true); }); it('does not explode from a bad initial configuration', () => { /* * The failure this guards against: nodes seeded almost on top of each other * divide by a near-zero distance, and the first tick throws the whole graph * to a radius of 1e10. The distance floor and the speed limit are what stop * it. */ const nodes: GraphNode[] = Array.from({ length: 30 }, (_, i) => ({ id: `n${i}`, position: { x: 1e-4 * i, y: 0, z: 0 }, })); const states = seedPositions(nodes, '3d'); let alpha = 1; for (let i = 0; i < 120; i += 1) { alpha = tick(states, [], indexOf(nodes), '3d', {}, alpha).alpha; } const radius = Math.max(...states.map(s => Math.hypot(s.x, s.y, s.z))); expect(radius).toBeLessThan(5000); expect(Number.isFinite(radius)).toBe(true); }); it('caps how far a node can move in one tick', () => { const nodes: GraphNode[] = [ { id: 'a', position: { x: 0, y: 0, z: 0 } }, { id: 'b', position: { x: 1e-5, y: 0, z: 0 } }, ]; const states = seedPositions(nodes, '3d'); const before = { ...states[0] }; tick(states, [], indexOf(nodes), '3d', { maxVelocity: 10 }); const moved = Math.hypot( states[0].x - before.x, states[0].y - before.y, states[0].z - before.z ); expect(moved).toBeLessThanOrEqual(10.0001); }); it('sheds energy every tick so it cannot orbit forever', () => { const nodes: GraphNode[] = [{ id: 'a' }, { id: 'b' }]; const states = seedPositions(nodes, '3d'); const first = tick(states, [], indexOf(nodes), '3d', {}, 1); const second = tick(states, [], indexOf(nodes), '3d', {}, first.alpha); expect(first.alpha).toBeLessThan(1); expect(second.alpha).toBeLessThan(first.alpha); }); it('settles on exhausted energy even if movement never falls', () => { const nodes: GraphNode[] = [{ id: 'a' }, { id: 'b' }]; const states = seedPositions(nodes, '3d'); // An unreachable movement threshold must still terminate, via alpha. // Any alpha whose decayed value lands under alphaMin (0.002). const result = tick(states, [], indexOf(nodes), '3d', { settleThreshold: -1 }, 0.0019); expect(result.settled).toBe(true); }); it('keeps every coordinate finite over a long run', () => { const nodes: GraphNode[] = Array.from({ length: 40 }, (_, i) => ({ id: `n${i}` })); const edges: GraphEdge[] = nodes .slice(1) .map((n, i) => ({ source: nodes[i].id, target: n.id })); const states = seedPositions(nodes, '3d'); for (let i = 0; i < 200; i += 1) tick(states, edges, indexOf(nodes), '3d'); expect( states.every(s => Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.z)) ).toBe(true); }); }); describe('layout spread', () => { const build = (size: number) => { const nodes: GraphNode[] = Array.from({ length: size }, (_, i) => ({ id: `n${i}` })); const edges: GraphEdge[] = nodes .slice(1) .map((n, i) => ({ source: nodes[i].id, target: n.id })); return { nodes, edges, states: seedPositions(nodes, '3d') }; }; const nearestPair = (states: ReturnType) => { let closest = Infinity; for (let i = 0; i < states.length; i += 1) { for (let j = i + 1; j < states.length; j += 1) { closest = Math.min( closest, Math.hypot( states[i].x - states[j].x, states[i].y - states[j].y, states[i].z - states[j].z ) ); } } return closest; }; it('does not collapse into a ball as the layout cools', () => { /* * The bug this guards: centering was the one force not scaled by alpha, so * it was the only one still acting once the graph cooled. A twelve-node * graph seeded at radius 89 settled at radius 9 — closer together than the * nodes' own diameter, which on screen read as "the circles are too big". */ const { nodes, edges, states } = build(12); const seeded = Math.max(...states.map(s => Math.hypot(s.x, s.y, s.z))); runToSettle(states, edges, indexOf(nodes), '3d', {}, 600); const settled = Math.max(...states.map(s => Math.hypot(s.x, s.y, s.z))); expect(settled).toBeGreaterThan(seeded * 0.5); }); it('keeps nodes far enough apart for their discs not to overlap', () => { const { nodes, edges, states } = build(40); runToSettle(states, edges, indexOf(nodes), '3d', {}, 600); // Default node radius is 6, so anything under 12 units means overlap. expect(nearestPair(states)).toBeGreaterThan(12); }); it('grows the layout as the graph grows, instead of packing tighter', () => { const small = build(12); const large = build(200); runToSettle(small.states, small.edges, indexOf(small.nodes), '3d', {}, 600); runToSettle(large.states, large.edges, indexOf(large.nodes), '3d', {}, 600); const radiusOf = (s: typeof small.states) => Math.max(...s.map(p => Math.hypot(p.x, p.y, p.z))); expect(radiusOf(large.states)).toBeGreaterThan(radiusOf(small.states)); }); it('cools the centering force along with the others', () => { // With alpha at zero no force acts at all, so nothing may move. const { nodes, edges, states } = build(8); states.forEach(state => { state.vx = 0; state.vy = 0; state.vz = 0; }); const before = states.map(s => ({ ...s })); tick(states, edges, indexOf(nodes), '3d', {}, 0); states.forEach((state, i) => { expect(state.x).toBeCloseTo(before[i].x, 9); expect(state.y).toBeCloseTo(before[i].y, 9); }); }); }); describe('runToSettle', () => { it('converges a small graph well inside the iteration cap', () => { const nodes: GraphNode[] = Array.from({ length: 12 }, (_, i) => ({ id: `n${i}` })); const edges: GraphEdge[] = nodes .slice(1) .map((n, i) => ({ source: nodes[i].id, target: n.id })); const states = seedPositions(nodes, '3d'); const iterations = runToSettle(states, edges, indexOf(nodes), '3d', {}, 400); expect(iterations).toBeLessThan(400); }); it('stops at the cap rather than looping forever', () => { const nodes: GraphNode[] = [{ id: 'a' }, { id: 'b' }]; const states = seedPositions(nodes, '3d'); // Neither movement nor alpha may end it, so only the cap can. expect( runToSettle(states, [], indexOf(nodes), '3d', { settleThreshold: -1, alphaDecay: 0 }, 25) ).toBe(25); }); it('converges in a bounded number of ticks regardless of size', () => { // Alpha decay makes the tick count a function of the settings, not the // graph — which is what lets the component predict how long settling takes. const counts = [8, 60, 200].map(size => { const nodes: GraphNode[] = Array.from({ length: size }, (_, i) => ({ id: `n${i}` })); const edges: GraphEdge[] = nodes .slice(1) .map((n, i) => ({ source: nodes[i].id, target: n.id })); return runToSettle(seedPositions(nodes, '3d'), edges, indexOf(nodes), '3d', {}, 600); }); counts.forEach(count => expect(count).toBeLessThan(400)); }); });