glp-companion/test/features/nutrition/nutrition_widget_test.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);
});
}