import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; import 'package:glp1_companion/features/medication/domain/entities/dose_entry.dart'; import 'package:glp1_companion/features/medication/domain/usecases/calculate_serum_level.dart'; import 'package:glp1_companion/features/medication/domain/value_objects/glp1_compound.dart'; /// Pharmacokinetics regression suite for the single-compartment decay model /// `C(t) = Σ D_i · 0.5^((t − t_i)/t_half)`. /// /// Focuses on the multi-dose / steady-state behaviour that titration /// planning depends on: /// /// * **Semaglutide** — `t½ = 168 h`, i.e. exactly one weekly interval, so a /// weekly dosing ladder has a per-interval decay factor `r = 0.5` and /// converges to `peak → 2·D`, `trough → D`. /// * **Tirzepatide** — `t½ = 120 h`, so weekly dosing decays by /// `r = 0.5^(168/120) = 0.5^1.4 ≈ 0.3789` per interval and converges to /// `peak → D/(1−r) ≈ 1.61·D`, `trough → D·r/(1−r) ≈ 0.61·D` — a wider /// peak-to-trough fluctuation than semaglutide, as observed clinically. DoseEntry _dose({ required String compound, required double mg, required DateTime administeredAt, }) => DoseEntry( compoundName: compound, doseMg: mg, administeredAt: administeredAt, ); /// Independent reference implementation of the decay model (sums the /// contribution of every dose in [doses] evaluated at [at]). double _referenceSerum({ required Iterable doses, required DateTime at, required double halfLifeHours, }) { var total = 0.0; for (final dose in doses) { final elapsedHours = at.difference(dose.administeredAt).inMilliseconds / Duration.millisecondsPerHour; if (elapsedHours < 0) continue; total += dose.doseMg * math.pow(0.5, elapsedHours / halfLifeHours).toDouble(); } return total; } void main() { const calculator = CalculateSerumLevel(); /// Fixed anchor so dose spacing is exact (no clock drift in assertions). final epoch = DateTime(2026, 1, 5, 8); const week = Duration(hours: 168); /// Weekly dosing ladder: [count] doses of [mg] spaced exactly one /// semaglutide half-life apart, starting at [epoch]. List weeklyLadder({ required String compound, required double mg, required int count, }) => [ for (var i = 0; i < count; i++) _dose( compound: compound, mg: mg, administeredAt: epoch.add(week * i), ), ]; group('Semaglutide weekly steady state (t½ = 168 h)', () { test('r = 0.5: trough converges to D, peak to 2·D', () { const doseMg = 1.0; // Peak after the 8th weekly dose: D·(2 − 0.5^7) ≈ 1.9922 mg. final peak = calculator.evaluate( doses: weeklyLadder(compound: 'Semaglutide', mg: doseMg, count: 8), at: epoch.add(week * 7), ); expect(peak, closeTo(doseMg * (2 - math.pow(0.5, 7)), 1e-9)); // Trough just before a hypothetical 9th dose: D·(1 − 0.5^8). final trough = calculator.evaluate( doses: weeklyLadder(compound: 'Semaglutide', mg: doseMg, count: 8), at: epoch.add(week * 8), ); expect(trough, closeTo(doseMg * (1 - math.pow(0.5, 8)), 1e-9)); // Both sit within 1 % of the analytic steady-state limits. expect(peak, closeTo(2 * doseMg, 0.01 * 2 * doseMg)); expect(trough, closeTo(doseMg, 0.01 * doseMg)); }); test('troughs rise monotonically week over week toward the plateau', () { final troughs = []; for (var n = 1; n <= 8; n++) { troughs.add( calculator.evaluate( doses: weeklyLadder(compound: 'Semaglutide', mg: 1, count: n), at: epoch.add(week * n), ), ); } for (var i = 1; i < troughs.length; i++) { expect(troughs[i], greaterThan(troughs[i - 1]), reason: 'accumulation must keep rising before steady state'); } expect(troughs.first, closeTo(0.5, 1e-9)); expect(troughs.last, closeTo(1, 0.01)); }); test('peak-to-trough fluctuation converges on one dose (r = 0.5)', () { final doses = weeklyLadder(compound: 'Semaglutide', mg: 2, count: 8); final peak = calculator.evaluate(doses: doses, at: epoch.add(week * 7)); final trough = calculator.evaluate(doses: doses, at: epoch.add(week * 8)); // At week 8 the spread is D·(1 − 0.5^8) and tightens onto the // steady-state fluctuation of exactly one dose (D = 2 mg) thereafter. expect( peak - trough, closeTo(2 * (1 - math.pow(0.5, 8)), 1e-9), ); }); }); group('Tirzepatide weekly steady state (t½ = 120 h)', () { // Weekly decay factor for a 120 h half-life: 0.5^(168/120) = 0.5^1.4. final r = math.pow(0.5, 168 / 120).toDouble(); test('accumulation matches the geometric series with r = 0.5^1.4', () { const doseMg = 2.5; const doses = 10; final ladder = weeklyLadder( compound: 'Tirzepatide', mg: doseMg, count: doses, ); // Peak just after the 10th dose: D·(1 − r^10)/(1 − r). final expectedPeak = doseMg * (1 - math.pow(r, doses)) / (1 - r); final peak = calculator.evaluate(doses: ladder, at: epoch.add(week * (doses - 1))); expect(peak, closeTo(expectedPeak, 1e-9)); // Trough just before the next dose: D·r·(1 − r^10)/(1 − r). final expectedTrough = expectedPeak * r; final trough = calculator.evaluate(doses: ladder, at: epoch.add(week * doses)); expect(trough, closeTo(expectedTrough, 1e-9)); // Converged within 0.1 % of the analytic steady state D/(1−r). expect(peak, closeTo(doseMg / (1 - r), 0.001 * doseMg / (1 - r))); }); test('one missed interval decays the latest dose by exactly one half-life', () { final doses = weeklyLadder(compound: 'Tirzepatide', mg: 5, count: 3); // 120 h (one t½) after the latest dose: 5 mg → 2.5 mg, plus the // residual of the two earlier weekly doses. final at = epoch.add(week * 2 + const Duration(hours: 120)); expect( calculator.evaluate(doses: doses, at: at), closeTo( _referenceSerum(doses: doses, at: at, halfLifeHours: 120), 1e-9, ), ); expect(calculator.evaluate(doses: doses, at: at), greaterThan(2.5)); }); test('weekly dosing fluctuates more than semaglutide (1/r > 2)', () { final semaLadder = weeklyLadder(compound: 'Semaglutide', mg: 1, count: 12); final tirzeLadder = weeklyLadder(compound: 'Tirzepatide', mg: 1, count: 12); double ratio(List doses) { final peak = calculator.evaluate(doses: doses, at: epoch.add(week * 11)); final trough = calculator.evaluate(doses: doses, at: epoch.add(week * 12)); return peak / trough; } expect(ratio(semaLadder), closeTo(2, 1e-6)); expect(ratio(tirzeLadder), closeTo(1 / r, 1e-6)); expect(ratio(tirzeLadder), greaterThan(ratio(semaLadder))); }); }); group('multi-dose superposition', () { test('mixed-age semaglutide doses sum independently', () { final doses = [ _dose( compound: 'Semaglutide', mg: 2, administeredAt: epoch.subtract(const Duration(hours: 336)), ), _dose( compound: 'Semaglutide', mg: 1, administeredAt: epoch.subtract(const Duration(hours: 168)), ), _dose(compound: 'Semaglutide', mg: 0.5, administeredAt: epoch), ]; // 2·0.5² + 1·0.5 + 0.5 = 1.5 mg. expect(calculator.evaluate(doses: doses, at: epoch), closeTo(1.5, 1e-9)); }); test('mixed-age tirzepatide doses sum independently', () { final doses = [ _dose( compound: 'Tirzepatide', mg: 5, administeredAt: epoch.subtract(const Duration(hours: 240)), ), _dose( compound: 'Tirzepatide', mg: 2.5, administeredAt: epoch.subtract(const Duration(hours: 120)), ), _dose(compound: 'Tirzepatide', mg: 2.5, administeredAt: epoch), ]; // 5·0.5² + 2.5·0.5 + 2.5 = 5.0 mg. expect(calculator.evaluate(doses: doses, at: epoch), closeTo(5, 1e-9)); }); test('titration ladder: 0.25 mg ×4 weeks then 0.5 mg ×4 (semaglutide)', () { final doses = [ for (var i = 0; i < 4; i++) _dose( compound: 'Ozempic', // Brand alias must resolve to t½ = 168 h. mg: 0.25, administeredAt: epoch.add(week * i), ), for (var i = 4; i < 8; i++) _dose( compound: 'Ozempic', mg: 0.5, administeredAt: epoch.add(week * i), ), ]; // Peak just after the first full 0.5 mg dose (week 5): // 0.5 + 0.25·(0.5 + 0.25 + 0.125 + 0.0625) = 0.734375 mg. final peakAt = epoch.add(week * 4); final expectedPeak = _referenceSerum( doses: doses, at: peakAt, halfLifeHours: Glp1Compound.semaglutide.halfLifeHours, ); expect(expectedPeak, closeTo(0.734375, 1e-9)); expect( calculator.evaluate(doses: doses, at: peakAt), closeTo(expectedPeak, 1e-9), ); // The trough before the next dose keeps accumulating (~0.4834 mg), // well above the old 0.25 mg-dose plateau trough. final nextTroughAt = epoch.add(week * 8); final trough = calculator.evaluate(doses: doses, at: nextTroughAt); expect( trough, closeTo( _referenceSerum( doses: doses, at: nextTroughAt, halfLifeHours: Glp1Compound.semaglutide.halfLifeHours, ), 1e-9, ), ); expect(trough, greaterThan(0.48)); expect(trough, lessThan(1)); }); test('longer half-life retains more of an identical history', () { final at = epoch.add(const Duration(hours: 120)); double retained(String compound) => calculator.evaluate( doses: [_dose(compound: compound, mg: 4, administeredAt: epoch)], at: at, ); // After 120 h: tirzepatide keeps 50 %, semaglutide ~61.4 %. expect(retained('Tirzepatide'), closeTo(2, 1e-9)); expect( retained('Semaglutide'), closeTo(4 * math.pow(0.5, 120 / 168), 1e-9), ); expect(retained('Semaglutide'), greaterThan(retained('Tirzepatide'))); }); test('five half-lives wash out > 96 % of a single dose', () { final at = epoch.add(const Duration(hours: 120 * 5)); expect( calculator.evaluate( doses: [_dose(compound: 'Tirzepatide', mg: 10, administeredAt: epoch)], at: at, ), closeTo(10 * math.pow(0.5, 5), 1e-9), // 0.3125 mg = 3.125 %. ); }); }); }