376 lines
12 KiB
Dart
376 lines
12 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;
|
|
bool cloudReady = false;
|
|
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<bool> isCloudSyncReady() async => cloudReady;
|
|
|
|
@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(
|
|
'native denial with a connected cloud partner still syncs partner '
|
|
'data', () async {
|
|
gateway.granted = false;
|
|
gateway.cloudReady = true;
|
|
gateway.weights = [
|
|
HealthMetric(
|
|
timestamp: DateTime.now().subtract(const Duration(hours: 1)),
|
|
value: 182,
|
|
unit: 'lb',
|
|
),
|
|
];
|
|
await load();
|
|
|
|
final ok = await notifier().syncFromHealth();
|
|
|
|
expect(ok, isTrue);
|
|
// The native grant is still requested exactly once…
|
|
expect(gateway.permissionRequests, 1);
|
|
final state = container.read(biometricControllerProvider).valueOrNull!;
|
|
// …but the denial no longer blocks the cloud-backed sync.
|
|
expect(state.statusMessage, contains('Synced 1 weight sample(s)'));
|
|
expect(state.statusMessage, isNot(contains('denied')));
|
|
expect(repository.entries, hasLength(1));
|
|
});
|
|
|
|
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));
|
|
});
|
|
});
|
|
} |