230 lines
7.4 KiB
Dart
230 lines
7.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.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';
|
|
import 'package:glp1_companion/features/nutrition/domain/entities/food_log_entry.dart';
|
|
import 'package:glp1_companion/features/nutrition/domain/entities/hydration_log_entry.dart';
|
|
import 'package:glp1_companion/features/nutrition/domain/entities/meal_type.dart';
|
|
import 'package:glp1_companion/features/nutrition/domain/repositories/nutrition_repository.dart';
|
|
import 'package:glp1_companion/features/nutrition/presentation/controllers/nutrition_controller.dart';
|
|
import 'package:glp1_companion/features/nutrition/presentation/screens/nutrition_screen.dart';
|
|
|
|
/// In-memory [INutritionRepository] fake with auto-incrementing ids, matching
|
|
/// the drift-backed behaviour the controller relies on.
|
|
class _FakeNutritionRepository implements INutritionRepository {
|
|
final List<FoodLogEntry> _foods = [];
|
|
final List<HydrationLogEntry> _waters = [];
|
|
int _nextId = 1;
|
|
|
|
@override
|
|
Stream<List<FoodLogEntry>> watchFoodLogs({int limit = 500}) =>
|
|
Stream.value(_foods.take(limit).toList());
|
|
|
|
@override
|
|
Future<List<FoodLogEntry>> getFoodLogs({
|
|
int limit = 500,
|
|
DateTime? from,
|
|
DateTime? to,
|
|
}) async =>
|
|
_foods
|
|
.where((entry) =>
|
|
(from == null || !entry.timestamp.isBefore(from)) &&
|
|
(to == null || entry.timestamp.isBefore(to)))
|
|
.take(limit)
|
|
.toList();
|
|
|
|
@override
|
|
Future<FoodLogEntry> saveFoodLog(FoodLogEntry entry) async {
|
|
final stored = entry.copyWith(id: entry.id ?? _nextId++);
|
|
_foods.removeWhere((existing) => existing.id == stored.id);
|
|
_foods.insert(0, stored);
|
|
return stored;
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteFoodLog(int id) async {
|
|
_foods.removeWhere((entry) => entry.id == id);
|
|
}
|
|
|
|
@override
|
|
Stream<List<HydrationLogEntry>> watchHydrationLogs({int limit = 500}) =>
|
|
Stream.value(_waters.take(limit).toList());
|
|
|
|
@override
|
|
Future<List<HydrationLogEntry>> getHydrationLogs({
|
|
int limit = 500,
|
|
DateTime? from,
|
|
DateTime? to,
|
|
}) async =>
|
|
_waters
|
|
.where((entry) =>
|
|
(from == null || !entry.timestamp.isBefore(from)) &&
|
|
(to == null || entry.timestamp.isBefore(to)))
|
|
.take(limit)
|
|
.toList();
|
|
|
|
@override
|
|
Future<HydrationLogEntry> saveHydrationLog(HydrationLogEntry entry) async {
|
|
final stored = entry.copyWith(id: entry.id ?? _nextId++);
|
|
_waters.insert(0, stored);
|
|
return stored;
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteHydrationLog(int id) async {
|
|
_waters.removeWhere((entry) => entry.id == id);
|
|
}
|
|
}
|
|
|
|
/// Empty [IHealthSyncRepository] fake for the weight-tracker card.
|
|
class _EmptyHealthSyncRepository implements IHealthSyncRepository {
|
|
@override
|
|
Stream<List<BiometricEntry>> watchBiometrics({int limit = 500}) =>
|
|
Stream.value(const []);
|
|
|
|
@override
|
|
Future<List<BiometricEntry>> getBiometrics({
|
|
int limit = 500,
|
|
DateTime? from,
|
|
DateTime? to,
|
|
}) async =>
|
|
const [];
|
|
|
|
@override
|
|
Future<BiometricEntry> saveBiometric(BiometricEntry entry) async => entry;
|
|
|
|
@override
|
|
Future<void> deleteBiometric(int id) async {}
|
|
|
|
@override
|
|
Future<BiometricEntry?> getLatestBiometric() async => null;
|
|
|
|
@override
|
|
Future<void> clearSyncedRange({
|
|
required BiometricSource source,
|
|
required DateTime start,
|
|
required DateTime end,
|
|
}) async {}
|
|
}
|
|
|
|
void main() {
|
|
late _FakeNutritionRepository repository;
|
|
|
|
setUp(() => repository = _FakeNutritionRepository());
|
|
|
|
Future<void> pumpScreen(WidgetTester tester) async {
|
|
// A tall viewport keeps the protein card, hydration card, and quick-add
|
|
// buttons visible without scrolling.
|
|
tester.view.physicalSize = const Size(1080, 2400);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
nutritionRepositoryProvider.overrideWithValue(repository),
|
|
healthSyncRepositoryProvider
|
|
.overrideWithValue(_EmptyHealthSyncRepository()),
|
|
],
|
|
child: const MaterialApp(home: NutritionScreen()),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
/// First [LinearProgressIndicator] is protein, second is hydration.
|
|
double proteinBar(WidgetTester tester) => tester
|
|
.widgetList<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
|
|
.first
|
|
.value!;
|
|
|
|
double hydrationBar(WidgetTester tester) => tester
|
|
.widgetList<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
|
|
.elementAt(1)
|
|
.value!;
|
|
|
|
testWidgets('progress bars react to a new protein log entry',
|
|
(tester) async {
|
|
await pumpScreen(tester);
|
|
|
|
expect(find.text('0.0g / 100g goal'), findsOneWidget);
|
|
expect(proteinBar(tester), 0);
|
|
|
|
final container = ProviderScope.containerOf(
|
|
tester.element(find.byType(NutritionScreen)),
|
|
);
|
|
final logged = await container
|
|
.read(nutritionControllerProvider.notifier)
|
|
.logFood(
|
|
mealType: MealType.breakfast,
|
|
description: 'Greek yogurt bowl',
|
|
proteinGrams: 40,
|
|
calories: 220,
|
|
);
|
|
expect(logged, isTrue);
|
|
await tester.pumpAndSettle();
|
|
|
|
// 40 g of a 100 g target -> the meter and total update reactively.
|
|
expect(find.text('40.0g / 100g goal'), findsOneWidget);
|
|
expect(proteinBar(tester), closeTo(0.4, 1e-9));
|
|
expect(hydrationBar(tester), 0);
|
|
});
|
|
|
|
testWidgets('quick-add water buttons advance the hydration meter',
|
|
(tester) async {
|
|
await pumpScreen(tester);
|
|
|
|
expect(find.text('0 oz / 96 oz goal'), findsOneWidget);
|
|
expect(hydrationBar(tester), 0);
|
|
|
|
await tester.tap(find.text('+8.0 oz'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('8 oz / 96 oz goal'), findsOneWidget);
|
|
expect(hydrationBar(tester), closeTo(8 / 96, 1e-9));
|
|
|
|
await tester.tap(find.text('+16.0 oz'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('24 oz / 96 oz goal'), findsOneWidget);
|
|
expect(hydrationBar(tester), closeTo(24 / 96, 1e-9));
|
|
});
|
|
|
|
testWidgets('both meters saturate and surface the success chips',
|
|
(tester) async {
|
|
await pumpScreen(tester);
|
|
|
|
final container = ProviderScope.containerOf(
|
|
tester.element(find.byType(NutritionScreen)),
|
|
);
|
|
final controller = container.read(nutritionControllerProvider.notifier);
|
|
|
|
await controller.logFood(
|
|
mealType: MealType.lunch,
|
|
description: 'Chicken bowl',
|
|
proteinGrams: 60,
|
|
calories: 520,
|
|
carbsGrams: 45,
|
|
fatGrams: 18,
|
|
);
|
|
await controller.logFood(
|
|
mealType: MealType.dinner,
|
|
description: 'Salmon plate',
|
|
proteinGrams: 40,
|
|
calories: 480,
|
|
);
|
|
await controller.logWater(96);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(proteinBar(tester), 1);
|
|
expect(hydrationBar(tester), 1);
|
|
// Only the protein card carries a "Goal met" chip; the hydration card
|
|
// signals completion through its saturated (teal) meter.
|
|
expect(find.text('Goal met'), findsOneWidget);
|
|
expect(find.text('100.0g / 100g goal'), findsOneWidget);
|
|
expect(find.text('96 oz / 96 oz goal'), findsOneWidget);
|
|
});
|
|
}
|