// graphLayout.test.js —— B3 单测:手写力导向布局(graphLayout.js) import { describe, it, expect } from 'vitest'; import { forceLayout, buildPalette, hashString } from '../../../../ui/src/components/bench/graphLayout.js'; describe('forceLayout 手写力导向(FR-KG-001)', () => { const small = { nodes: [ { id: 'a', label: 'A', type: 'gene' }, { id: 'b', label: 'B', type: 'tissue' }, { id: 'c', label: 'C', type: 'process' }, ], edges: [ { source: 'a', target: 'b', relation: 'expressed_in' }, { source: 'a', target: 'c', relation: 'involved_in' }, ], }; it('输出节点带有限位置(无 NaN/Infinity),且包围盒适配画布', () => { const { nodes } = forceLayout(small, { width: 800, height: 600 }); expect(nodes).toHaveLength(3); for (const n of nodes) { expect(Number.isFinite(n.x)).toBe(true); expect(Number.isFinite(n.y)).toBe(true); expect(n.x).toBeGreaterThanOrEqual(0); expect(n.x).toBeLessThanOrEqual(800); expect(n.y).toBeGreaterThanOrEqual(0); expect(n.y).toBeLessThanOrEqual(600); } }); it('边端点引用不存在的节点 → 被过滤,不抛错', () => { const { nodes, edges } = forceLayout({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ source: 'a', target: 'ghost' }, { source: 'a', target: 'b' }], }); expect(nodes).toHaveLength(2); expect(edges).toHaveLength(1); }); it('空图 → 空结果', () => { const { nodes } = forceLayout({ nodes: [], edges: [] }); expect(nodes).toHaveLength(0); }); it('单节点不崩溃', () => { const { nodes } = forceLayout({ nodes: [{ id: 'x', type: 'gene' }], edges: [] }); expect(nodes).toHaveLength(1); expect(Number.isFinite(nodes[0].x)).toBe(true); }); it('大图(60 节点)也能稳定布局(供降级前渲染兜底)', () => { const nodes = []; const edges = []; for (let i = 0; i < 60; i += 1) { nodes.push({ id: `n${i}`, type: `t${i % 5}` }); if (i > 0) edges.push({ source: `n${i}`, target: `n${i - 1}` }); } const out = forceLayout({ nodes, edges }, { width: 800, height: 600, iterations: 120 }); expect(out.nodes).toHaveLength(60); for (const n of out.nodes) { expect(Number.isFinite(n.x)).toBe(true); expect(Number.isFinite(n.y)).toBe(true); } }); it('相同输入两次运行结果一致(确定性布局)', () => { const a = forceLayout(small, { width: 800, height: 600 }); const b = forceLayout(small, { width: 800, height: 600 }); for (let i = 0; i < a.nodes.length; i += 1) { expect(a.nodes[i].x).toBeCloseTo(b.nodes[i].x, 6); expect(a.nodes[i].y).toBeCloseTo(b.nodes[i].y, 6); } }); }); describe('buildPalette 稳定映射', () => { it('hashString 稳定', () => { expect(hashString('gene')).toBe(hashString('gene')); expect(hashString('gene')).not.toBe(hashString('tissue')); }); it('调色板大小写敏感(Gene 与 gene 视为不同 type,与后端一致)', () => { const p = buildPalette(['Gene', 'gene']); expect(p.get('Gene')).not.toBe(p.get('gene')); }); });