import { describe, it, expect } from 'vitest'; import { layoutRadial, selectNodes, nodeRadius, labelPlacement, parallelOffsets, shortLabel, parseFocus, evidenceLevelRank, compareChains, edgeStroke, sortGroups, NODE_TYPE_ORDER, type GraphNode, type NodeType, type PathChain } from '@/lib/graph-model'; const mk = (type: NodeType, i: number, degree = 1): GraphNode => ({ type, id: `${type}-${i}`, ref: `${type}-${i}`, label: `${type} ${i}`, href: `/${type}/${i}`, degree }); function many(counts: Partial>, degreeOf = (i: number) => 1 + (i % 7)): GraphNode[] { const out: GraphNode[] = []; for (const [t, n] of Object.entries(counts) as Array<[NodeType, number]>) for (let i = 0; i < n; i++) out.push(mk(t, i, degreeOf(i))); return out; } const TAU = Math.PI * 2; describe('layoutRadial', () => { it('places every drawn node inside the viewBox with room for its label', () => { const nodes = many({ cancer: 20, gene: 15, variant: 10, drug: 12, trial: 10, approval: 3 }); const l = layoutRadial(nodes, { size: 760, maxNodes: 60 }); expect(l.nodes.length).toBe(60); for (const p of l.nodes) { expect(p.x - p.r).toBeGreaterThanOrEqual(0); expect(p.y - p.r).toBeGreaterThanOrEqual(0); expect(p.x + p.r).toBeLessThanOrEqual(760); expect(p.y + p.r).toBeLessThanOrEqual(760); // radial label margin: the label anchor stays inside the box too const lp = labelPlacement(p); expect(lp.x).toBeGreaterThanOrEqual(0); expect(lp.x).toBeLessThanOrEqual(760); expect(lp.y).toBeGreaterThanOrEqual(0); expect(lp.y).toBeLessThanOrEqual(760); // the ring leaves the configured label margin free expect(Math.hypot(p.x - l.cx, p.y - l.cy)).toBeLessThanOrEqual(760 / 2 - 140 + 0.001); } }); it('keeps sectors in the fixed entity-type order and never overlapping', () => { const nodes = many({ drug: 9, cancer: 4, trial: 6, gene: 11, approval: 2, variant: 7 }); const l = layoutRadial(nodes); const types = l.sectors.map((s) => s.type); expect(types).toEqual(NODE_TYPE_ORDER.filter((t) => types.includes(t))); for (let i = 1; i < l.sectors.length; i++) { expect(l.sectors[i]!.start).toBeGreaterThan(l.sectors[i - 1]!.end); } const span = l.sectors[l.sectors.length - 1]!.end - l.sectors[0]!.start; expect(span).toBeLessThan(TAU); // every node's angle lies inside its own sector for (const p of l.nodes) { const s = l.sectors.find((x) => x.type === p.node.type)!; expect(p.angle).toBeGreaterThanOrEqual(s.start); expect(p.angle).toBeLessThanOrEqual(s.end); } }); it('gives adjacent nodes enough angular spacing for an 11 px radial label at 60 nodes', () => { const nodes = many({ cancer: 10, gene: 10, variant: 10, drug: 10, trial: 10, approval: 10 }); const l = layoutRadial(nodes, { size: 760, maxNodes: 60 }); const sorted = [...l.nodes].sort((a, b) => a.angle - b.angle); for (let i = 1; i < sorted.length; i++) { const a = sorted[i - 1]!; const b = sorted[i]!; if (a.node.type !== b.node.type) continue; // sectors are separated by gaps const arc = (b.angle - a.angle) * a.ring; expect(arc).toBeGreaterThan(12); } }); it('is deterministic and independent of input order', () => { const nodes = many({ cancer: 7, gene: 5, drug: 6 }); const a = layoutRadial(nodes); const b = layoutRadial([...nodes].reverse()); expect(a.nodes.map((p) => [p.node.id, p.x.toFixed(6), p.y.toFixed(6)])).toEqual(b.nodes.map((p) => [p.node.id, p.x.toFixed(6), p.y.toFixed(6)])); }); it('orders nodes inside a sector by degree then label', () => { const nodes = [mk('gene', 1, 2), mk('gene', 2, 9), mk('gene', 3, 2)]; const l = layoutRadial(nodes); expect(l.nodes.map((p) => p.node.id)).toEqual(['gene-2', 'gene-1', 'gene-3']); }); it('handles an empty neighbourhood and a single node', () => { const empty = layoutRadial([]); expect(empty.nodes).toEqual([]); expect(empty.sectors).toEqual([]); const one = layoutRadial([mk('drug', 1)]); expect(one.nodes.length).toBe(1); expect(one.sectors.length).toBe(1); // a lone node sits at the centre of its sector const s = one.sectors[0]!; expect(one.nodes[0]!.angle).toBeCloseTo((s.start + s.end) / 2, 9); }); it('reports hidden nodes when the cap is exceeded', () => { const l = layoutRadial(many({ cancer: 50, gene: 50 }), { maxNodes: 60 }); expect(l.nodes.length).toBe(60); expect(l.hidden).toBe(40); }); }); describe('selectNodes', () => { it('keeps every type represented (round-robin by degree) and is deterministic', () => { const nodes = many({ cancer: 40, gene: 40, approval: 1, trial: 2 }); const { drawn, hidden } = selectNodes(nodes, 10); expect(drawn.length).toBe(10); expect(hidden).toBe(73); expect(drawn.some((n) => n.type === 'approval')).toBe(true); expect(drawn.filter((n) => n.type === 'trial').length).toBe(2); const again = selectNodes([...nodes].reverse(), 10); expect(again.drawn.map((n) => n.id)).toEqual(drawn.map((n) => n.id)); }); }); describe('helpers', () => { it('nodeRadius grows with the log of the degree and is capped', () => { expect(nodeRadius(0)).toBe(4); expect(nodeRadius(1)).toBeGreaterThan(nodeRadius(0)); expect(nodeRadius(1000)).toBeLessThanOrEqual(13); }); it('labelPlacement flips text on the left half so it never reads upside down', () => { const right = labelPlacement({ node: mk('gene', 1), x: 500, y: 380, r: 5, angle: 0, ring: 120 }); const left = labelPlacement({ node: mk('gene', 1), x: 260, y: 380, r: 5, angle: Math.PI, ring: 120 }); expect(right.anchor).toBe('start'); expect(left.anchor).toBe('end'); expect(Math.abs(left.rotate)).toBeCloseTo(360, 5); }); it('parallelOffsets are centred and symmetric', () => { expect(parallelOffsets(1)).toEqual([0]); expect(parallelOffsets(3)).toEqual([-3, 0, 3]); expect(parallelOffsets(2)).toEqual([-1.5, 1.5]); }); it('shortLabel truncates with an ellipsis', () => { expect(shortLabel('Lung Non-Small Cell Carcinoma', 12)).toBe('Lung Non-Sm…'); expect(shortLabel('EGFR')).toBe('EGFR'); }); it('edgeStroke is dashed for derived and observed links, solid for curated / regulatory', () => { expect(edgeStroke({ derived: true, evidenceCategory: 'regulatory_status' })).toBe('dashed'); expect(edgeStroke({ derived: false, evidenceCategory: 'observed_data' })).toBe('dashed'); expect(edgeStroke({ derived: false, evidenceCategory: 'curated_evidence' })).toBe('solid'); expect(edgeStroke({ derived: false, evidenceCategory: 'regulatory_status' })).toBe('solid'); }); it('sortGroups puts regulatory and curated relationships before derived counts', () => { const g = sortGroups([{ relationshipType: 'STUDIED_IN' }, { relationshipType: 'ASSOCIATED_WITH' }, { relationshipType: 'APPROVED_FOR' }, { relationshipType: 'ZZ_UNKNOWN' }]); expect(g.map((x) => x.relationshipType)).toEqual(['APPROVED_FOR', 'ASSOCIATED_WITH', 'STUDIED_IN', 'ZZ_UNKNOWN']); }); }); describe('parseFocus', () => { it('parses type:ref pairs and bare identifiers', () => { expect(parseFocus('cancer:melanoma')).toEqual({ type: 'cancer', ref: 'melanoma' }); expect(parseFocus('Gene: egfr')).toEqual({ type: 'gene', ref: 'egfr' }); expect(parseFocus('NCT04487080')).toEqual({ type: 'trial', ref: 'NCT04487080' }); expect(parseFocus('CI-DRUG-00000464')).toEqual({ type: 'drug', ref: 'CI-DRUG-00000464' }); expect(parseFocus('TP53')).toEqual({ type: 'gene', ref: 'TP53' }); expect(parseFocus('lung adenocarcinoma')).toEqual({ type: 'cancer', ref: 'lung adenocarcinoma' }); }); it('rejects unknown types and empty input', () => { expect(parseFocus('publication:123')).toBeNull(); expect(parseFocus('')).toBeNull(); expect(parseFocus('drug:')).toBeNull(); }); }); describe('evidence ranking', () => { it('ranks CIViC A–E, then native phase scales, unknown last', () => { expect(evidenceLevelRank('A')).toBeLessThan(evidenceLevelRank('B')); expect(evidenceLevelRank('E')).toBeLessThan(evidenceLevelRank('INHIBITOR')); expect(evidenceLevelRank('4')).toBeLessThan(evidenceLevelRank('1')); expect(evidenceLevelRank(null)).toBe(99); }); it('compareChains orders by level, support, frequency, then names (stable)', () => { const base: PathChain = { cancer: { id: 'c', slug: 'c', name: 'C' }, gene: { id: 'g', symbol: 'EGFR', frequency: 0.2, casesAffected: 20, casesProfiled: 100, cohorts: 1 }, variant: { id: 'v', slug: 'v', name: 'L858R' }, drug: { id: 'd', slug: 'd', name: 'osimertinib' }, edge: { evidenceLevel: 'B', direction: 'sensitivity', supportCount: 3, sourceIds: [], provenanceIds: [], contextIds: [], contextNames: [] }, approval: null, trials: null, }; const a = { ...base, edge: { ...base.edge, evidenceLevel: 'A', supportCount: 1 } }; const b = { ...base, edge: { ...base.edge, supportCount: 10 } }; const c = { ...base, gene: { ...base.gene, symbol: 'KRAS', frequency: 0.5 } }; const sorted = [base, c, b, a].sort(compareChains); expect(sorted.map((x) => `${x.edge.evidenceLevel}/${x.edge.supportCount}/${x.gene.symbol}`)).toEqual(['A/1/EGFR', 'B/10/EGFR', 'B/3/KRAS', 'B/3/EGFR']); }); });