277 lines
8.3 KiB
Dart
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);
|
|
});
|
|
});
|
|
} |