glp-companion/test/features/medication/presentation/medication_controller_test....

277 lines
8.3 KiB
Dart

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<DoseEntry> entries = [];
final StreamController<List<DoseEntry>> _changes =
StreamController<List<DoseEntry>>.broadcast(sync: true);
int _nextId = 1;
bool failNextSave = false;
void seed(List<DoseEntry> seeded) {
entries.addAll(seeded);
_emit();
}
void _emit() => _changes.add(List.unmodifiable(entries));
@override
Stream<List<DoseEntry>> watchDoseEntries({int limit = 500}) => _changes.stream;
@override
Future<List<DoseEntry>> getDoseEntries({int limit = 500}) async =>
List.unmodifiable(entries);
@override
Future<DoseEntry> 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<void> deleteDoseEntry(int id) async {
entries.removeWhere((entry) => entry.id == id);
_emit();
}
@override
Future<DoseEntry?> getLatestAdministeredDose() async =>
entries.isEmpty ? null : entries.first;
Future<void> close() => _changes.close();
}
/// Records scheduling calls instead of touching platform channels.
class _RecordingNotificationService implements INotificationService {
final List<DateTime> scheduledTimes = [];
int permissionRequests = 0;
@override
Future<void> initialize() async {}
@override
Future<bool> requestPermissions() async {
permissionRequests++;
return true;
}
@override
Future<bool> scheduleDoseReminder({
required DateTime scheduledAt,
required String title,
required String body,
int id = kDoseReminderNotificationId,
String? payload,
}) async {
scheduledTimes.add(scheduledAt);
return true;
}
@override
Future<void> cancelReminder(int id) async {}
@override
Future<void> 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<StateError>());
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);
});
});
}