spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { describe, it, expect } from 'vitest';2import { layoutRadial, selectNodes, nodeRadius, labelPlacement, parallelOffsets, shortLabel, parseFocus, evidenceLevelRank, compareChains, edgeStroke, sortGroups, NODE_TYPE_ORDER, type GraphNode, type NodeType, type PathChain } from '@/lib/graph-model';34const mk = (type: NodeType, i: number, degree = 1): GraphNode => ({ type, id: `${type}-${i}`, ref: `${type}-${i}`, label: `${type} ${i}`, href: `/${type}/${i}`, degree });56function many(counts: Partial<Record<NodeType, number>>, degreeOf = (i: number) => 1 + (i % 7)): GraphNode[] {7 const out: GraphNode[] = [];8 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)));9 return out;10}1112const TAU = Math.PI * 2;1314describe('layoutRadial', () => {15 it('places every drawn node inside the viewBox with room for its label', () => {16 const nodes = many({ cancer: 20, gene: 15, variant: 10, drug: 12, trial: 10, approval: 3 });17 const l = layoutRadial(nodes, { size: 760, maxNodes: 60 });18 expect(l.nodes.length).toBe(60);19 for (const p of l.nodes) {20 expect(p.x - p.r).toBeGreaterThanOrEqual(0);21 expect(p.y - p.r).toBeGreaterThanOrEqual(0);22 expect(p.x + p.r).toBeLessThanOrEqual(760);23 expect(p.y + p.r).toBeLessThanOrEqual(760);24 // radial label margin: the label anchor stays inside the box too25 const lp = labelPlacement(p);26 expect(lp.x).toBeGreaterThanOrEqual(0);27 expect(lp.x).toBeLessThanOrEqual(760);28 expect(lp.y).toBeGreaterThanOrEqual(0);29 expect(lp.y).toBeLessThanOrEqual(760);30 // the ring leaves the configured label margin free31 expect(Math.hypot(p.x - l.cx, p.y - l.cy)).toBeLessThanOrEqual(760 / 2 - 140 + 0.001);32 }33 });3435 it('keeps sectors in the fixed entity-type order and never overlapping', () => {36 const nodes = many({ drug: 9, cancer: 4, trial: 6, gene: 11, approval: 2, variant: 7 });37 const l = layoutRadial(nodes);38 const types = l.sectors.map((s) => s.type);39 expect(types).toEqual(NODE_TYPE_ORDER.filter((t) => types.includes(t)));40 for (let i = 1; i < l.sectors.length; i++) {41 expect(l.sectors[i]!.start).toBeGreaterThan(l.sectors[i - 1]!.end);42 }43 const span = l.sectors[l.sectors.length - 1]!.end - l.sectors[0]!.start;44 expect(span).toBeLessThan(TAU);45 // every node's angle lies inside its own sector46 for (const p of l.nodes) {47 const s = l.sectors.find((x) => x.type === p.node.type)!;48 expect(p.angle).toBeGreaterThanOrEqual(s.start);49 expect(p.angle).toBeLessThanOrEqual(s.end);50 }51 });5253 it('gives adjacent nodes enough angular spacing for an 11 px radial label at 60 nodes', () => {54 const nodes = many({ cancer: 10, gene: 10, variant: 10, drug: 10, trial: 10, approval: 10 });55 const l = layoutRadial(nodes, { size: 760, maxNodes: 60 });56 const sorted = [...l.nodes].sort((a, b) => a.angle - b.angle);57 for (let i = 1; i < sorted.length; i++) {58 const a = sorted[i - 1]!;59 const b = sorted[i]!;60 if (a.node.type !== b.node.type) continue; // sectors are separated by gaps61 const arc = (b.angle - a.angle) * a.ring;62 expect(arc).toBeGreaterThan(12);63 }64 });6566 it('is deterministic and independent of input order', () => {67 const nodes = many({ cancer: 7, gene: 5, drug: 6 });68 const a = layoutRadial(nodes);69 const b = layoutRadial([...nodes].reverse());70 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)]));71 });7273 it('orders nodes inside a sector by degree then label', () => {74 const nodes = [mk('gene', 1, 2), mk('gene', 2, 9), mk('gene', 3, 2)];75 const l = layoutRadial(nodes);76 expect(l.nodes.map((p) => p.node.id)).toEqual(['gene-2', 'gene-1', 'gene-3']);77 });7879 it('handles an empty neighbourhood and a single node', () => {80 const empty = layoutRadial([]);81 expect(empty.nodes).toEqual([]);82 expect(empty.sectors).toEqual([]);83 const one = layoutRadial([mk('drug', 1)]);84 expect(one.nodes.length).toBe(1);85 expect(one.sectors.length).toBe(1);86 // a lone node sits at the centre of its sector87 const s = one.sectors[0]!;88 expect(one.nodes[0]!.angle).toBeCloseTo((s.start + s.end) / 2, 9);89 });9091 it('reports hidden nodes when the cap is exceeded', () => {92 const l = layoutRadial(many({ cancer: 50, gene: 50 }), { maxNodes: 60 });93 expect(l.nodes.length).toBe(60);94 expect(l.hidden).toBe(40);95 });96});9798describe('selectNodes', () => {99 it('keeps every type represented (round-robin by degree) and is deterministic', () => {100 const nodes = many({ cancer: 40, gene: 40, approval: 1, trial: 2 });101 const { drawn, hidden } = selectNodes(nodes, 10);102 expect(drawn.length).toBe(10);103 expect(hidden).toBe(73);104 expect(drawn.some((n) => n.type === 'approval')).toBe(true);105 expect(drawn.filter((n) => n.type === 'trial').length).toBe(2);106 const again = selectNodes([...nodes].reverse(), 10);107 expect(again.drawn.map((n) => n.id)).toEqual(drawn.map((n) => n.id));108 });109});110111describe('helpers', () => {112 it('nodeRadius grows with the log of the degree and is capped', () => {113 expect(nodeRadius(0)).toBe(4);114 expect(nodeRadius(1)).toBeGreaterThan(nodeRadius(0));115 expect(nodeRadius(1000)).toBeLessThanOrEqual(13);116 });117 it('labelPlacement flips text on the left half so it never reads upside down', () => {118 const right = labelPlacement({ node: mk('gene', 1), x: 500, y: 380, r: 5, angle: 0, ring: 120 });119 const left = labelPlacement({ node: mk('gene', 1), x: 260, y: 380, r: 5, angle: Math.PI, ring: 120 });120 expect(right.anchor).toBe('start');121 expect(left.anchor).toBe('end');122 expect(Math.abs(left.rotate)).toBeCloseTo(360, 5);123 });124 it('parallelOffsets are centred and symmetric', () => {125 expect(parallelOffsets(1)).toEqual([0]);126 expect(parallelOffsets(3)).toEqual([-3, 0, 3]);127 expect(parallelOffsets(2)).toEqual([-1.5, 1.5]);128 });129 it('shortLabel truncates with an ellipsis', () => {130 expect(shortLabel('Lung Non-Small Cell Carcinoma', 12)).toBe('Lung Non-Sm…');131 expect(shortLabel('EGFR')).toBe('EGFR');132 });133 it('edgeStroke is dashed for derived and observed links, solid for curated / regulatory', () => {134 expect(edgeStroke({ derived: true, evidenceCategory: 'regulatory_status' })).toBe('dashed');135 expect(edgeStroke({ derived: false, evidenceCategory: 'observed_data' })).toBe('dashed');136 expect(edgeStroke({ derived: false, evidenceCategory: 'curated_evidence' })).toBe('solid');137 expect(edgeStroke({ derived: false, evidenceCategory: 'regulatory_status' })).toBe('solid');138 });139 it('sortGroups puts regulatory and curated relationships before derived counts', () => {140 const g = sortGroups([{ relationshipType: 'STUDIED_IN' }, { relationshipType: 'ASSOCIATED_WITH' }, { relationshipType: 'APPROVED_FOR' }, { relationshipType: 'ZZ_UNKNOWN' }]);141 expect(g.map((x) => x.relationshipType)).toEqual(['APPROVED_FOR', 'ASSOCIATED_WITH', 'STUDIED_IN', 'ZZ_UNKNOWN']);142 });143});144145describe('parseFocus', () => {146 it('parses type:ref pairs and bare identifiers', () => {147 expect(parseFocus('cancer:melanoma')).toEqual({ type: 'cancer', ref: 'melanoma' });148 expect(parseFocus('Gene: egfr')).toEqual({ type: 'gene', ref: 'egfr' });149 expect(parseFocus('NCT04487080')).toEqual({ type: 'trial', ref: 'NCT04487080' });150 expect(parseFocus('CI-DRUG-00000464')).toEqual({ type: 'drug', ref: 'CI-DRUG-00000464' });151 expect(parseFocus('TP53')).toEqual({ type: 'gene', ref: 'TP53' });152 expect(parseFocus('lung adenocarcinoma')).toEqual({ type: 'cancer', ref: 'lung adenocarcinoma' });153 });154 it('rejects unknown types and empty input', () => {155 expect(parseFocus('publication:123')).toBeNull();156 expect(parseFocus('')).toBeNull();157 expect(parseFocus('drug:')).toBeNull();158 });159});160161describe('evidence ranking', () => {162 it('ranks CIViC A–E, then native phase scales, unknown last', () => {163 expect(evidenceLevelRank('A')).toBeLessThan(evidenceLevelRank('B'));164 expect(evidenceLevelRank('E')).toBeLessThan(evidenceLevelRank('INHIBITOR'));165 expect(evidenceLevelRank('4')).toBeLessThan(evidenceLevelRank('1'));166 expect(evidenceLevelRank(null)).toBe(99);167 });168 it('compareChains orders by level, support, frequency, then names (stable)', () => {169 const base: PathChain = {170 cancer: { id: 'c', slug: 'c', name: 'C' },171 gene: { id: 'g', symbol: 'EGFR', frequency: 0.2, casesAffected: 20, casesProfiled: 100, cohorts: 1 },172 variant: { id: 'v', slug: 'v', name: 'L858R' },173 drug: { id: 'd', slug: 'd', name: 'osimertinib' },174 edge: { evidenceLevel: 'B', direction: 'sensitivity', supportCount: 3, sourceIds: [], provenanceIds: [], contextIds: [], contextNames: [] },175 approval: null,176 trials: null,177 };178 const a = { ...base, edge: { ...base.edge, evidenceLevel: 'A', supportCount: 1 } };179 const b = { ...base, edge: { ...base.edge, supportCount: 10 } };180 const c = { ...base, gene: { ...base.gene, symbol: 'KRAS', frequency: 0.5 } };181 const sorted = [base, c, b, a].sort(compareChains);182 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']);183 });184});185