import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:glp1_companion/core/platform/health_gateway.dart'; import 'package:glp1_companion/features/health_sync/domain/entities/biometric_entry.dart'; import 'package:glp1_companion/features/health_sync/domain/repositories/health_sync_repository.dart'; import 'package:glp1_companion/features/health_sync/presentation/controllers/biometric_controller.dart'; /// In-memory [IHealthSyncRepository] with the same `[from, to)` and /// newest-first semantics as the drift implementation. class _FakeHealthSyncRepository implements IHealthSyncRepository { final List entries = []; int _nextId = 1; bool failNextSave = false; List _sorted(Iterable source) { final list = source.toList() ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); return List.unmodifiable(list); } static bool _inRange(DateTime timestamp, DateTime? from, DateTime? to) { if (from != null && timestamp.isBefore(from)) return false; if (to != null && !timestamp.isBefore(to)) return false; return true; } @override Stream> watchBiometrics({int limit = 500}) => Stream.value(_sorted(entries)); @override Future> getBiometrics({ int limit = 500, DateTime? from, DateTime? to, }) async => _sorted(entries.where((e) => _inRange(e.timestamp, from, to))); @override Future saveBiometric(BiometricEntry entry) async { if (failNextSave) { failNextSave = false; throw StateError('write failed'); } final stored = BiometricEntry( id: entry.id ?? _nextId++, timestamp: entry.timestamp, weightLbs: entry.weightLbs, activeCaloriesBurned: entry.activeCaloriesBurned, source: entry.source, ); final index = entries.indexWhere((e) => e.id == stored.id); if (index == -1) { entries.add(stored); } else { entries[index] = stored; } return stored; } @override Future deleteBiometric(int id) async { entries.removeWhere((entry) => entry.id == id); } @override Future getLatestBiometric() async => entries.isEmpty ? null : _sorted(entries).first; @override Future clearSyncedRange({ required BiometricSource source, required DateTime start, required DateTime end, }) async { entries.removeWhere( (entry) => entry.source == source && !entry.timestamp.isBefore(start) && entry.timestamp.isBefore(end), ); } } /// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect. class _FakeHealthGateway extends IHealthGateway { bool nativeSupported = true; bool granted = true; List weights = []; List energies = []; int permissionRequests = 0; int weightFetches = 0; @override bool get supportsNativeHealth => nativeSupported; @override NativeHealthPlatform? get nativePlatform => nativeSupported ? NativeHealthPlatform.healthConnect : null; @override Future requestPermissions() async { permissionRequests++; return granted; } @override Future> fetchWeightHistory({ required DateTime start, required DateTime end, }) async { weightFetches++; return weights; } @override Future> fetchActiveEnergyBurned({ required DateTime start, required DateTime end, }) async => energies; @override Future writeWeight({ required double weightLbs, required DateTime timestamp, }) async => nativeSupported; } void main() { late _FakeHealthSyncRepository repository; late _FakeHealthGateway gateway; late ProviderContainer container; setUp(() { repository = _FakeHealthSyncRepository(); gateway = _FakeHealthGateway(); container = ProviderContainer( overrides: [ healthSyncRepositoryProvider.overrideWithValue(repository), healthGatewayProvider.overrideWithValue(gateway), ], ); addTearDown(container.dispose); }); Future load() => container.read(biometricControllerProvider.future); BiometricController notifier() => container.read(biometricControllerProvider.notifier); BiometricEntry seed({ required DateTime timestamp, required double weightLbs, double? activeCaloriesBurned, BiometricSource source = BiometricSource.manual, }) { final entry = BiometricEntry( id: repository.entries.length + 1, timestamp: timestamp, weightLbs: weightLbs, activeCaloriesBurned: activeCaloriesBurned, source: source, ); repository.entries.add(entry); return entry; } group('BiometricController manual entry', () { test('logWeight persists a manual sample and exposes it as latest', () async { await load(); final ok = await notifier().logWeight(weightLbs: 182.5); expect(ok, isTrue); final state = container.read(biometricControllerProvider).valueOrNull!; expect(state.latestWeightLbs, 182.5); expect(state.latest!.source, BiometricSource.manual); expect(state.statusMessage, contains('182.5 lb')); expect(repository.entries, hasLength(1)); }); test('logWeight rejects non-positive values', () async { await load(); expect(await notifier().logWeight(weightLbs: 0), isFalse); expect(await notifier().logWeight(weightLbs: -3), isFalse); expect(repository.entries, isEmpty); }); test('deleteEntry removes a sample', () async { await load(); await notifier().logWeight(weightLbs: 200); final id = container .read(biometricControllerProvider) .valueOrNull! .latest! .id!; await notifier().deleteEntry(id); final state = container.read(biometricControllerProvider).valueOrNull!; expect(state.history, isEmpty); expect(state.latestWeightLbs, isNull); }); test('failed writes keep prior history and surface the error', () async { await load(); await notifier().logWeight(weightLbs: 175); repository.failNextSave = true; final ok = await notifier().logWeight(weightLbs: 176); expect(ok, isFalse); final state = container.read(biometricControllerProvider).valueOrNull!; expect(state.latestWeightLbs, 175); expect(state.error, isNotNull); }); }); group('BiometricController trends', () { test('computes 7-day and 30-day deltas against the nearest baseline', () async { final now = DateTime.now(); seed(timestamp: now, weightLbs: 200); seed(timestamp: now.subtract(const Duration(days: 8)), weightLbs: 205); seed(timestamp: now.subtract(const Duration(days: 31)), weightLbs: 210); final state = await load(); expect(state.latestWeightLbs, 200); expect(state.weight7DayDelta, closeTo(-5, 1e-9)); expect(state.weight30DayDelta, closeTo(-10, 1e-9)); }); test('returns null deltas when history does not reach back', () async { seed(timestamp: DateTime.now(), weightLbs: 190); final state = await load(); expect(state.latestWeightLbs, 190); expect(state.weight7DayDelta, isNull); expect(state.weight30DayDelta, isNull); }); test('latestActiveCaloriesBurned surfaces the newest energy sample', () async { seed( timestamp: DateTime.now(), weightLbs: 180, activeCaloriesBurned: 420, ); final state = await load(); expect(state.latestActiveCaloriesBurned, 420); }); }); group('BiometricController native sync', () { test('fallback platform short-circuits without touching the gateway', () async { gateway.nativeSupported = false; await load(); final ok = await notifier().syncFromHealth(); expect(ok, isFalse); expect(gateway.permissionRequests, 0); expect(gateway.weightFetches, 0); final state = container.read(biometricControllerProvider).valueOrNull!; expect(state.statusMessage, contains('unavailable')); expect(repository.entries, isEmpty); }); test('denied permission reports failure and stores nothing', () async { gateway.granted = false; await load(); final ok = await notifier().syncFromHealth(); expect(ok, isFalse); expect(gateway.permissionRequests, 1); final state = container.read(biometricControllerProvider).valueOrNull!; expect(state.statusMessage, contains('denied')); expect(repository.entries, isEmpty); }); test( 'imports weights and per-day active energy, then re-syncs without ' 'duplicates', () async { final now = DateTime.now(); final eightDaysAgo = now.subtract(const Duration(days: 8)); gateway.weights = [ HealthMetric( timestamp: now.subtract(const Duration(hours: 2)), value: 180, unit: 'lb', ), HealthMetric(timestamp: eightDaysAgo, value: 185, unit: 'lb'), ]; gateway.energies = [ HealthMetric( timestamp: now.subtract(const Duration(hours: 3)), value: 100, unit: 'kcal', ), HealthMetric( timestamp: now.subtract(const Duration(hours: 1)), value: 150, unit: 'kcal', ), HealthMetric( timestamp: eightDaysAgo.add(const Duration(hours: 1)), value: 400, unit: 'kcal', ), ]; await load(); final ok = await notifier().syncFromHealth(); expect(ok, isTrue); final state = container.read(biometricControllerProvider).valueOrNull!; expect(state.statusMessage, contains('Synced 2 weight sample(s)')); expect(state.statusMessage, contains('2 day(s) of active energy')); expect(state.history, hasLength(2)); expect( state.history.every((e) => e.source == BiometricSource.healthConnect), isTrue, ); // Today's sample carries the 100 + 150 kcal day total. expect(state.history.first.activeCaloriesBurned, 250); // The 8-day-old sample (newest-first ordering => last) carries its // 400 kcal day total. expect(state.history.last.activeCaloriesBurned, 400); expect(state.history.last.weightLbs, 185); // Re-sync replaces the previous import instead of stacking rows. await notifier().syncFromHealth(); final afterResync = container.read(biometricControllerProvider).valueOrNull!; expect(afterResync.history, hasLength(2)); }); }); }