import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:glp1_companion/core/platform/notification_service.dart'; import 'package:glp1_companion/features/medication/domain/entities/dose_entry.dart'; import 'package:glp1_companion/features/medication/domain/entities/injection_site.dart'; import 'package:glp1_companion/features/medication/domain/repositories/medication_repository.dart'; import 'package:glp1_companion/features/medication/domain/value_objects/glp1_compound.dart'; import 'package:glp1_companion/features/medication/presentation/controllers/medication_controller.dart'; /// In-memory [IMedicationRepository] that broadcasts every mutation. class _FakeMedicationRepository implements IMedicationRepository { final List entries = []; final StreamController> _changes = StreamController>.broadcast(sync: true); int _nextId = 1; bool failNextSave = false; void seed(List seeded) { entries.addAll(seeded); _emit(); } void _emit() => _changes.add(List.unmodifiable(entries)); @override Stream> watchDoseEntries({int limit = 500}) => _changes.stream; @override Future> getDoseEntries({int limit = 500}) async => List.unmodifiable(entries); @override Future saveDoseEntry(DoseEntry entry) async { if (failNextSave) { failNextSave = false; throw StateError('write failed'); } final stored = DoseEntry( id: entry.id ?? _nextId++, compoundName: entry.compoundName, doseMg: entry.doseMg, administeredAt: entry.administeredAt, scheduledAt: entry.scheduledAt, injectionSite: entry.injectionSite, notes: entry.notes, ); final index = entries.indexWhere((e) => e.id == stored.id); if (index == -1) { entries.insert(0, stored); } else { entries[index] = stored; } _emit(); return stored; } @override Future deleteDoseEntry(int id) async { entries.removeWhere((entry) => entry.id == id); _emit(); } @override Future getLatestAdministeredDose() async => entries.isEmpty ? null : entries.first; Future close() => _changes.close(); } /// Records scheduling calls instead of touching platform channels. class _RecordingNotificationService implements INotificationService { final List scheduledTimes = []; int permissionRequests = 0; @override Future initialize() async {} @override Future requestPermissions() async { permissionRequests++; return true; } @override Future scheduleDoseReminder({ required DateTime scheduledAt, required String title, required String body, int id = kDoseReminderNotificationId, String? payload, }) async { scheduledTimes.add(scheduledAt); return true; } @override Future cancelReminder(int id) async {} @override Future cancelAllReminders() async {} } DoseEntry _dose({ int? id, String compound = 'Semaglutide', double doseMg = 1, required DateTime administeredAt, InjectionSite? site, }) => DoseEntry( id: id, compoundName: compound, doseMg: doseMg, administeredAt: administeredAt, injectionSite: site, ); void main() { late _FakeMedicationRepository repository; late _RecordingNotificationService notifications; setUp(() { repository = _FakeMedicationRepository(); notifications = _RecordingNotificationService(); }); tearDown(() => repository.close()); MedicationController buildController() { final controller = MedicationController(repository, notifications); addTearDown(controller.dispose); return controller; } group('MedicationController', () { test('starts in a loading state before history arrives', () { final controller = buildController(); expect(controller.state.isLoading, isTrue); expect(controller.state.entries, isEmpty); expect(controller.state.suggestedSite, InjectionSite.values.first); }); test('loads history, computes serum level, and suggests a rotation site', () async { final controller = buildController(); final oneHalfLifeAgo = DateTime.now().subtract(Glp1Compound.semaglutide.dosingInterval); repository.seed([ _dose( administeredAt: oneHalfLifeAgo, site: InjectionSite.abdomenRightUpper, ), ]); expect(controller.state.isLoading, isFalse); expect(controller.state.entries, hasLength(1)); // 1 mg after one semaglutide half-life decays to ~0.5 mg. expect(controller.state.serumLevelMg, closeTo(0.5, 0.01)); expect( controller.state.suggestedSite, isNot(InjectionSite.abdomenRightUpper), ); expect( controller.state.suggestedSite, InjectionSite.leastRecentlyUsed({ InjectionSite.abdomenRightUpper: oneHalfLifeAgo, }), ); }); test('nextDoseDueAt follows the compound dosing interval', () async { final controller = buildController(); final administeredAt = DateTime.now().subtract(const Duration(days: 1)); repository.seed([ _dose( compound: 'Liraglutide', administeredAt: administeredAt, site: InjectionSite.thighRightLateral, ), ]); expect( controller.state.nextDoseDueAt, administeredAt.add(Glp1Compound.liraglutide.dosingInterval), ); }); test('logInjection persists the entry and schedules the next reminder', () async { final controller = buildController(); final administeredAt = DateTime(2026, 10, 16, 8); final saved = await controller.logInjection( compoundName: 'Tirzepatide', doseMg: 2.5, site: InjectionSite.thighRightAnterior, administeredAt: administeredAt, ); expect(saved, isTrue); expect(repository.entries, hasLength(1)); expect( repository.entries.single.injectionSite, InjectionSite.thighRightAnterior, ); expect(notifications.permissionRequests, 1); expect(notifications.scheduledTimes, [ administeredAt.add(Glp1Compound.tirzepatide.dosingInterval), ]); }); test('rotation moves away from the most recently used quadrant', () async { final controller = buildController(); await controller.logInjection( compoundName: 'Semaglutide', doseMg: 0.25, site: InjectionSite.abdomenRightUpper, ); expect(controller.state.suggestedSite, InjectionSite.abdomenRightLower); }); test('logInjection failure returns false and records the error', () async { final controller = buildController(); repository.failNextSave = true; final saved = await controller.logInjection( compoundName: 'Semaglutide', doseMg: 0.25, site: InjectionSite.armLeftTricep, ); expect(saved, isFalse); expect(controller.state.error, isA()); expect(notifications.scheduledTimes, isEmpty); }); test('deleteInjection removes an entry from the stream', () async { final controller = buildController(); final stored = await repository.saveDoseEntry( _dose( administeredAt: DateTime.now(), site: InjectionSite.armRightLateral, ), ); expect(controller.state.entries, hasLength(1)); final removed = await controller.deleteInjection(stored.id!); expect(removed, isTrue); expect(controller.state.entries, isEmpty); }); test('refreshSerumLevel re-evaluates decay at the requested time', () async { final controller = buildController(); final administeredAt = DateTime.now().subtract(const Duration(hours: 168)); repository.seed([ _dose(doseMg: 2, administeredAt: administeredAt), ]); final seededLevel = controller.state.serumLevelMg; final later = administeredAt.add(const Duration(hours: 336)); controller.refreshSerumLevel(now: later); // Two half-lives after administration: 2 mg -> 0.5 mg, below the // freshly seeded estimate. expect(controller.state.serumLevelMg, closeTo(0.5, 1e-9)); expect(controller.state.serumLevelMg, lessThan(seededLevel)); expect(controller.state.evaluatedAt, later); }); }); }