glp-companion/test/features/health_sync/presentation/biometric_controller_test.dart

346 lines
10 KiB
Dart

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<BiometricEntry> entries = [];
int _nextId = 1;
bool failNextSave = false;
List<BiometricEntry> _sorted(Iterable<BiometricEntry> 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<List<BiometricEntry>> watchBiometrics({int limit = 500}) =>
Stream.value(_sorted(entries));
@override
Future<List<BiometricEntry>> getBiometrics({
int limit = 500,
DateTime? from,
DateTime? to,
}) async =>
_sorted(entries.where((e) => _inRange(e.timestamp, from, to)));
@override
Future<BiometricEntry> 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<void> deleteBiometric(int id) async {
entries.removeWhere((entry) => entry.id == id);
}
@override
Future<BiometricEntry?> getLatestBiometric() async =>
entries.isEmpty ? null : _sorted(entries).first;
@override
Future<void> 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<HealthMetric> weights = [];
List<HealthMetric> energies = [];
int permissionRequests = 0;
int weightFetches = 0;
@override
bool get supportsNativeHealth => nativeSupported;
@override
NativeHealthPlatform? get nativePlatform =>
nativeSupported ? NativeHealthPlatform.healthConnect : null;
@override
Future<bool> requestPermissions() async {
permissionRequests++;
return granted;
}
@override
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
}) async {
weightFetches++;
return weights;
}
@override
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
}) async =>
energies;
@override
Future<bool> 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<BiometricState> 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));
});
});
}