spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { describe, expect, it } from 'vitest';2import { computeShares, eligibility, log2Ratio, per1000Deaths, RESEARCH_GAP_FORMULA_VERSION, RESEARCH_GAP_THRESHOLDS } from '../src/research-gap.js';34const T = { minDeaths: 100, minActivity: 1 };56describe('eligibility', () => {7 it('rejects missing or small burden with a stored reason', () => {8 expect(eligibility({ deaths: null, activeTrials: 5, publications5y: 5 }, T)).toEqual({ eligible: false, reason: 'no_mortality_observation', trialRatio: false, publicationRatio: false });9 expect(eligibility({ deaths: NaN, activeTrials: 5, publications5y: 5 }, T).reason).toBe('no_mortality_observation');10 const small = eligibility({ deaths: 99, activeTrials: 5, publications5y: 5 }, T);11 expect(small.eligible).toBe(false);12 expect(small.reason).toMatch(/deaths_below_threshold \(99 < 100\)/);13 });14 it('accepts deaths at the threshold and gates each ratio on its own activity', () => {15 expect(eligibility({ deaths: 100, activeTrials: 1, publications5y: 1 }, T)).toEqual({ eligible: true, reason: null, trialRatio: true, publicationRatio: true });16 const noTrials = eligibility({ deaths: 5000, activeTrials: 0, publications5y: 12 }, T);17 expect(noTrials).toEqual({ eligible: true, reason: null, trialRatio: false, publicationRatio: true });18 });19 it('uses the seeded thresholds by default', () => {20 expect(RESEARCH_GAP_THRESHOLDS.minDeaths).toBe(100);21 expect(RESEARCH_GAP_THRESHOLDS.minActivity).toBe(1);22 expect(eligibility({ deaths: 99, activeTrials: 1, publications5y: 1 }).eligible).toBe(false);23 expect(RESEARCH_GAP_FORMULA_VERSION).toBe('ci-research-gap-components-v1');24 });25});2627describe('log2Ratio', () => {28 it('is 0 when shares match, ±1 when they differ by a factor of two', () => {29 expect(log2Ratio(0.25, 0.25)).toBe(0);30 expect(log2Ratio(0.5, 0.25)).toBe(1);31 expect(log2Ratio(0.25, 0.5)).toBe(-1);32 expect(log2Ratio(0.3, 0.1)).toBeCloseTo(Math.log2(3), 6);33 });34 it('is undefined (null) for zero, negative, null or non-finite operands', () => {35 expect(log2Ratio(0.2, 0)).toBeNull();36 expect(log2Ratio(0, 0.2)).toBeNull();37 expect(log2Ratio(-1, 0.2)).toBeNull();38 expect(log2Ratio(null, 0.2)).toBeNull();39 expect(log2Ratio(0.2, undefined)).toBeNull();40 expect(log2Ratio(Infinity, 0.2)).toBeNull();41 expect(log2Ratio(0.2, NaN)).toBeNull();42 });43 it('rounds to 6 decimals', () => {44 const v = log2Ratio(1 / 3, 1 / 7)!;45 expect(v).toBe(Math.round(Math.log2(7 / 3) * 1e6) / 1e6);46 });47});4849describe('per1000Deaths', () => {50 it('divides activity by thousands of deaths', () => {51 expect(per1000Deaths(50, 1000)).toBe(50);52 expect(per1000Deaths(2622, 125000)).toBeCloseTo(20.976, 3);53 expect(per1000Deaths(0, 1000)).toBe(0);54 });55 it('is null without positive deaths', () => {56 expect(per1000Deaths(5, 0)).toBeNull();57 expect(per1000Deaths(5, null)).toBeNull();58 expect(per1000Deaths(5, -3)).toBeNull();59 expect(per1000Deaths(NaN, 100)).toBeNull();60 });61});6263describe('computeShares', () => {64 const inputs = [65 { id: 'lung', deaths: 60_000, activeTrials: 300, publications5y: 9_000 },66 { id: 'breast', deaths: 30_000, activeTrials: 300, publications5y: 9_000 },67 { id: 'rare', deaths: 10_000, activeTrials: 200, publications5y: 2_000 },68 { id: 'tiny', deaths: 50, activeTrials: 400, publications5y: 100 }, // below minDeaths → excluded from every sum69 ];7071 it('computes shares over the eligible set only and they sum to 1', () => {72 const { rows, sums } = computeShares(inputs, T);73 expect(sums).toEqual({ deaths: 100_000, activeTrials: 800, publications5y: 20_000, eligible: 3 });74 const byId = new Map(rows.map((r) => [r.id, r]));75 expect(byId.get('lung')!.deathShare).toBe(0.6);76 expect(byId.get('breast')!.deathShare).toBe(0.3);77 expect(byId.get('rare')!.deathShare).toBe(0.1);78 expect(byId.get('lung')!.trialShare).toBe(0.375);79 expect(byId.get('rare')!.trialShare).toBe(0.25);80 expect(byId.get('lung')!.publicationShare).toBe(0.45);81 const eligible = rows.filter((r) => r.eligible);82 for (const key of ['deathShare', 'trialShare', 'publicationShare'] as const) {83 const sum = eligible.reduce((s, r) => s + (r[key] ?? 0), 0);84 expect(sum).toBeCloseTo(1, 9);85 }86 });8788 it('derives the log2 ratios from the shares and keeps the ineligible row with nulls', () => {89 const { rows } = computeShares(inputs, T);90 const byId = new Map(rows.map((r) => [r.id, r]));91 expect(byId.get('lung')!.trialGapRatio).toBeCloseTo(Math.log2(0.6 / 0.375), 6); // +0.678: more deaths than trials would suggest92 expect(byId.get('rare')!.trialGapRatio).toBeCloseTo(Math.log2(0.1 / 0.25), 6); // −1.32: comparatively well trialled93 expect(byId.get('rare')!.researchGapRatio).toBe(0); // 10% of deaths, 10% of publications94 expect(byId.get('lung')!.trialsPer1000Deaths).toBe(5);95 expect(byId.get('lung')!.publicationsPer1000Deaths).toBe(150);96 const tiny = byId.get('tiny')!;97 expect(tiny.eligible).toBe(false);98 expect(tiny.reason).toMatch(/deaths_below_threshold/);99 expect(tiny.deathShare).toBeNull();100 expect(tiny.trialShare).toBeNull();101 expect(tiny.trialGapRatio).toBeNull();102 expect(tiny.researchGapRatio).toBeNull();103 // Per-1,000 values are still informative for an ineligible row (no cross-entity denominator involved).104 expect(tiny.trialsPer1000Deaths).toBe(8000);105 });106107 it('handles zero activity: share 0, ratio null, others unaffected', () => {108 const { rows, sums } = computeShares(109 [110 { id: 'a', deaths: 1000, activeTrials: 0, publications5y: 10 },111 { id: 'b', deaths: 1000, activeTrials: 10, publications5y: 10 },112 ],113 T,114 );115 expect(sums.activeTrials).toBe(10);116 const a = rows.find((r) => r.id === 'a')!;117 const b = rows.find((r) => r.id === 'b')!;118 expect(a.eligible).toBe(true);119 expect(a.trialRatio).toBe(false);120 expect(a.trialShare).toBe(0);121 expect(a.trialGapRatio).toBeNull();122 expect(a.researchGapRatio).toBe(0);123 expect(a.trialsPer1000Deaths).toBe(0);124 expect(b.trialShare).toBe(1);125 expect(b.trialGapRatio).toBe(-1); // 50% of deaths, 100% of trials126 });127128 it('single eligible entity: every share is 1 and every ratio 0', () => {129 const { rows } = computeShares([{ id: 'only', deaths: 500, activeTrials: 3, publications5y: 7 }], T);130 expect(rows[0]).toMatchObject({ deathShare: 1, trialShare: 1, publicationShare: 1, trialGapRatio: 0, researchGapRatio: 0 });131 });132133 it('no eligible entity: sums are zero and shares null (no division by zero)', () => {134 const { rows, sums } = computeShares([{ id: 'x', deaths: 10, activeTrials: 3, publications5y: 7 }], T);135 expect(sums).toEqual({ deaths: 0, activeTrials: 0, publications5y: 0, eligible: 0 });136 expect(rows[0]!.deathShare).toBeNull();137 });138139 it('all activity zero in the scope: shares null, ratios null, nothing NaN', () => {140 const { rows } = computeShares(141 [142 { id: 'a', deaths: 1000, activeTrials: 0, publications5y: 0 },143 { id: 'b', deaths: 3000, activeTrials: 0, publications5y: 0 },144 ],145 T,146 );147 for (const r of rows) {148 expect(r.deathShare).not.toBeNull();149 expect(r.trialShare).toBeNull();150 expect(r.publicationShare).toBeNull();151 expect(r.trialGapRatio).toBeNull();152 expect(r.researchGapRatio).toBeNull();153 for (const v of Object.values(r)) if (typeof v === 'number') expect(Number.isNaN(v)).toBe(false);154 }155 });156157 it('rounds shares to 6 decimals and is order-independent', () => {158 const a = computeShares(inputs, T);159 const b = computeShares([...inputs].reverse(), T);160 const sortById = (r: { id: string }[]) => [...r].sort((x, y) => x.id.localeCompare(y.id));161 expect(sortById(a.rows)).toEqual(sortById(b.rows));162 const { rows } = computeShares(163 [164 { id: 'a', deaths: 1000, activeTrials: 1, publications5y: 1 },165 { id: 'b', deaths: 2000, activeTrials: 1, publications5y: 1 },166 { id: 'c', deaths: 4000, activeTrials: 1, publications5y: 1 },167 ],168 T,169 );170 expect(rows.find((r) => r.id === 'a')!.deathShare).toBe(0.142857); // 1/7 rounded171 expect(rows.find((r) => r.id === 'a')!.trialShare).toBe(0.333333);172 // Ratio is computed from unrounded shares: log2((1/7)/(1/3)) = log2(3/7)173 expect(rows.find((r) => r.id === 'a')!.trialGapRatio).toBeCloseTo(Math.log2(3 / 7), 6);174 });175});176