SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
9.2 KB · 185 lines typescript
Raw Blame History
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