import { describe, expect, it } from 'vitest'; import { STOP_REASON_CATEGORIES, STOP_REASON_RULES, classifyStopReason, isStopReasonCategory, stopReasonBreakdown } from './trial-stop-reasons.js'; describe('classifyStopReason', () => { it('returns not_stated for null, undefined and blank text', () => { for (const v of [null, undefined, '', ' ', '\n']) { const r = classifyStopReason(v); expect(r.category).toBe('not_stated'); expect(r.matched).toEqual([]); } }); it('returns other_stated when text is present but no rule matches (never infers)', () => { const r = classifyStopReason('Sponsor is focusing on studies which can enable registration of duvelisib'); expect(r.category).toBe('other_stated'); expect(r.matched).toEqual([]); }); it.each([ ['slow accrual', 'enrollment'], ['Poor recruitment', 'enrollment'], ['low enrollment', 'enrollment'], ['Low enrolment (UK spelling)', 'enrollment'], ['No funding', 'funding'], ['Costs for antibody production rose; supplemental funding request not approved.', 'funding'], ['Financial constraints', 'funding'], ['Budget cuts', 'funding'], ['Business decision', 'sponsor_decision'], ["Sponsor's decision", 'sponsor_decision'], ['Decision of the sponsor', 'sponsor_decision'], ['Change in development strategy', 'sponsor_decision'], ['Portfolio prioritization', 'sponsor_decision'], ['Company reorganisation', 'sponsor_decision'], ['Terminated due to unacceptable toxicity', 'safety'], ['Safety concerns', 'safety'], ['Serious adverse events', 'safety'], ['Lack of efficacy', 'efficacy'], ['Stopped for futility', 'efficacy'], ['Lack of clinical benefit', 'efficacy'], ['Stopped after interim analysis', 'efficacy'], ['Drug supply issues', 'drug_supply'], ['Drug availability', 'drug_supply'], ['Drug manufacturing process and procedure review', 'drug_supply'], ['PI left the institution', 'investigator'], ['Investigator relocated', 'investigator'], ['COVID-19 pandemic', 'covid'], ['Site closed during the pandemic', 'covid'], ])('classifies %j as %s', (text, expected) => { expect(classifyStopReason(text).category).toBe(expected); }); it('is case-insensitive', () => { expect(classifyStopReason('SLOW ACCRUAL').category).toBe('enrollment'); expect(classifyStopReason('covid').category).toBe('covid'); }); it('uses word boundaries so unrelated words do not match', () => { // "refund" must not match funding; "companion" must not match company; "recruit" inside "unrecruitable" must not match. expect(classifyStopReason('refund issued to participants').category).toBe('other_stated'); expect(classifyStopReason('companion diagnostic unavailable').category).toBe('other_stated'); }); it('applies precedence and reports every matched category', () => { const r = classifyStopReason('slow enrollment and lack of funding'); expect(r.category).toBe('enrollment'); expect(r.matched).toEqual(['enrollment', 'funding']); const s = classifyStopReason('Enrollment halted for safety reasons'); expect(s.category).toBe('safety'); expect(s.matched).toEqual(['safety', 'enrollment']); const t = classifyStopReason('Business decision: slow accrual'); expect(t.category).toBe('enrollment'); expect(t.matched).toEqual(['enrollment', 'sponsor_decision']); }); it('is deterministic and carries the rules version', () => { const a = classifyStopReason('slow accrual'); const b = classifyStopReason('slow accrual'); expect(a).toEqual(b); expect(a.rulesVersion).toBe('ci-stop-reasons-v1'); }); }); describe('rules table', () => { it('lists every ruled category exactly once, in precedence order, with the broadest last', () => { const cats = STOP_REASON_RULES.map((r) => r.category); expect(new Set(cats).size).toBe(cats.length); expect(cats[cats.length - 1]).toBe('sponsor_decision'); expect(STOP_REASON_CATEGORIES.slice(0, cats.length)).toEqual(cats); }); it('documents at least one keyword per pattern group', () => { for (const r of STOP_REASON_RULES) { expect(r.keywords.length).toBeGreaterThan(0); expect(r.patterns.length).toBeGreaterThan(0); } }); }); describe('stopReasonBreakdown', () => { it('counts every category with zeros and sums to the input size', () => { const b = stopReasonBreakdown(['slow accrual', null, '', 'No funding', 'unknown reason', 'toxicity']); expect(Object.keys(b)).toEqual([...STOP_REASON_CATEGORIES]); expect(b.enrollment).toBe(1); expect(b.not_stated).toBe(2); expect(b.funding).toBe(1); expect(b.other_stated).toBe(1); expect(b.safety).toBe(1); expect(Object.values(b).reduce((s, n) => s + n, 0)).toBe(6); }); it('is empty-safe', () => { const b = stopReasonBreakdown([]); expect(Object.values(b).every((n) => n === 0)).toBe(true); }); }); describe('isStopReasonCategory', () => { it('accepts known categories and rejects others', () => { expect(isStopReasonCategory('enrollment')).toBe(true); expect(isStopReasonCategory('not_stated')).toBe(true); expect(isStopReasonCategory('bogus')).toBe(false); }); });