import { describe, expect, it } from 'vitest'; import { TRIAL_INTEL_ACTIVE_STATUSES, TRIAL_INTEL_THRESHOLDS, TRIAL_INTELLIGENCE_FORMULA_VERSION, burdenNormalized, growthWindows, growthYoy, hhi, terminationShare } from './trial-intelligence.js'; describe('growthWindows', () => { it('builds two adjacent, non-overlapping 12-month windows ending on asOf (exclusive)', () => { const w = growthWindows('2026-09-11'); expect(w).toEqual({ asOf: '2026-09-11', new12m: { from: '2025-09-11', to: '2026-09-11' }, prior12m: { from: '2024-09-11', to: '2025-09-11' } }); expect(w.prior12m.to).toBe(w.new12m.from); }); it('clamps month-end days (leap day, 31st)', () => { expect(growthWindows('2024-02-29').new12m.from).toBe('2023-02-28'); expect(growthWindows('2025-03-31').prior12m.from).toBe('2023-03-31'); expect(growthWindows('2025-05-31').new12m.from).toBe('2024-05-31'); expect(growthWindows('2025-12-31').new12m.from).toBe('2024-12-31'); }); it('rejects malformed dates', () => { expect(() => growthWindows('2026-9-1')).toThrow(); expect(() => growthWindows('nope')).toThrow(); }); }); describe('growthYoy', () => { it('computes (new − prior) / prior', () => { expect(growthYoy(120, 100)).toBeCloseTo(0.2); expect(growthYoy(80, 100)).toBeCloseTo(-0.2); expect(growthYoy(100, 100)).toBe(0); }); it('is null under the prior-window threshold (default 20)', () => { expect(growthYoy(50, 19)).toBeNull(); expect(growthYoy(50, 20)).toBeCloseTo(1.5); expect(growthYoy(5, 0)).toBeNull(); expect(growthYoy(5, 2, 2)).toBeCloseTo(1.5); }); it('threshold matches the persisted constant', () => { expect(TRIAL_INTEL_THRESHOLDS.growthMinPriorTrials).toBe(20); }); }); describe('hhi', () => { it('is 1 for a single holder and 1/n for n equal holders', () => { expect(hhi([42])).toBe(1); expect(hhi([10, 10, 10, 10])).toBeCloseTo(0.25); }); it('is Σ share² for an uneven distribution', () => { // shares 0.5, 0.3, 0.2 → 0.25 + 0.09 + 0.04 expect(hhi([50, 30, 20])).toBeCloseTo(0.38); }); it('is null when empty or all zero, ignores negative / non-finite counts', () => { expect(hhi([])).toBeNull(); expect(hhi([0, 0])).toBeNull(); expect(hhi([5, -1, Number.NaN, 5])).toBeCloseTo(0.5); }); }); describe('terminationShare', () => { it('computes (terminated + withdrawn) / (completed + terminated + withdrawn)', () => { expect(terminationShare(70, 20, 10)).toBeCloseTo(0.3); expect(terminationShare(100, 0, 0)).toBe(0); }); it('is null when the terminal denominator is under 30', () => { expect(terminationShare(20, 5, 4)).toBeNull(); expect(terminationShare(20, 5, 5)).toBeCloseTo(1 / 3); expect(terminationShare(0, 0, 0)).toBeNull(); }); it('threshold matches the persisted constant', () => { expect(TRIAL_INTEL_THRESHOLDS.terminationMinTerminalTrials).toBe(30); }); }); describe('burdenNormalized', () => { it('computes trials per 1,000 deaths and per 100,000 cases', () => { const r = burdenNormalized(500, 125_000, 250_000); expect(r.per1000Deaths).toBeCloseTo(4); expect(r.per100kCases).toBeCloseTo(200); }); it('is null when deaths are missing or under 100', () => { expect(burdenNormalized(10, null, 1000)).toEqual({ per1000Deaths: null, per100kCases: null }); expect(burdenNormalized(10, 99, 1000)).toEqual({ per1000Deaths: null, per100kCases: null }); expect(burdenNormalized(10, 100, 1000).per1000Deaths).toBeCloseTo(100); }); it('leaves the incidence ratio null when incidence is missing or zero', () => { expect(burdenNormalized(10, 1000, null).per100kCases).toBeNull(); expect(burdenNormalized(10, 1000, 0).per100kCases).toBeNull(); }); it('zero active trials gives zero, never null (a true zero)', () => { expect(burdenNormalized(0, 1000, 2000)).toEqual({ per1000Deaths: 0, per100kCases: 0 }); }); }); describe('constants', () => { it('formula version and active statuses match entity_counters', () => { expect(TRIAL_INTELLIGENCE_FORMULA_VERSION).toBe('ci-trial-intel-v1'); expect([...TRIAL_INTEL_ACTIVE_STATUSES]).toEqual(['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING']); expect(TRIAL_INTEL_THRESHOLDS.burdenGeography).toBe('USA'); expect(TRIAL_INTEL_THRESHOLDS.terminationSince).toBe('2010-01-01'); }); });