import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.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'; /// In-memory [INutritionRepository] honoring the `[from, to)` range filter. class _FakeNutritionRepository implements INutritionRepository { final List foods = []; final List hydration = []; int _nextId = 1; bool failNextSave = false; 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; } List _sortedFood(Iterable entries) { final list = entries.toList() ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); return List.unmodifiable(list); } List _sortedWater(Iterable entries) { final list = entries.toList() ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); return List.unmodifiable(list); } void _maybeFail() { if (failNextSave) { failNextSave = false; throw StateError('write failed'); } } @override Stream> watchFoodLogs({int limit = 500}) => Stream.value(_sortedFood(foods)); @override Future> getFoodLogs({ int limit = 500, DateTime? from, DateTime? to, }) async => _sortedFood(foods.where((e) => _inRange(e.timestamp, from, to))); @override Future saveFoodLog(FoodLogEntry entry) async { _maybeFail(); final stored = FoodLogEntry( id: entry.id ?? _nextId++, timestamp: entry.timestamp, mealType: entry.mealType, description: entry.description, proteinGrams: entry.proteinGrams, calories: entry.calories, carbsGrams: entry.carbsGrams, fatGrams: entry.fatGrams, ); final index = foods.indexWhere((e) => e.id == stored.id); if (index == -1) { foods.add(stored); } else { foods[index] = stored; } return stored; } @override Future deleteFoodLog(int id) async { foods.removeWhere((entry) => entry.id == id); } @override Stream> watchHydrationLogs({int limit = 500}) => Stream.value(_sortedWater(hydration)); @override Future> getHydrationLogs({ int limit = 500, DateTime? from, DateTime? to, }) async => _sortedWater(hydration.where((e) => _inRange(e.timestamp, from, to))); @override Future saveHydrationLog(HydrationLogEntry entry) async { _maybeFail(); final stored = HydrationLogEntry( id: entry.id ?? _nextId++, timestamp: entry.timestamp, volumeOz: entry.volumeOz, ); hydration.add(stored); return stored; } @override Future deleteHydrationLog(int id) async { hydration.removeWhere((entry) => entry.id == id); } } void main() { late _FakeNutritionRepository repository; late ProviderContainer container; final today = DateTime.now(); setUp(() { repository = _FakeNutritionRepository(); container = ProviderContainer( overrides: [ nutritionRepositoryProvider.overrideWithValue(repository), ], ); addTearDown(container.dispose); }); Future load() => container.read(nutritionControllerProvider.future); group('NutritionController daily targets', () { test('build loads today-only entries and applies 100g/96oz targets', () async { repository.foods.add( FoodLogEntry( id: 1, timestamp: DateTime(today.year, today.month, today.day, 8), mealType: MealType.breakfast, description: 'Eggs', proteinGrams: 30, calories: 320, ), ); repository.hydration.add( HydrationLogEntry( id: 2, timestamp: DateTime(today.year, today.month, today.day, 9), volumeOz: 16, ), ); // Outside today's window: must be excluded. repository.foods.add( FoodLogEntry( id: 3, timestamp: DateTime(today.year, today.month, today.day - 1, 8), mealType: MealType.dinner, description: 'Yesterday stew', proteinGrams: 50, calories: 600, ), ); final state = await load(); expect(state.foodLogs, hasLength(1)); expect(state.totalProteinGrams, 30); expect(state.proteinTargetGrams, 100); expect(state.proteinProgress, 0.3); expect(state.totalWaterOz, 16); expect(state.hydrationTargetOz, 96); expect(state.hydrationProgress, closeTo(16 / 96, 1e-9)); }); test('logFood updates totals and groups meals by type', () async { final initial = await load(); expect(initial.foodLogs, isEmpty); expect(initial.mealsByType, isEmpty); final notifier = container.read(nutritionControllerProvider.notifier); final okBreakfast = await notifier.logFood( mealType: MealType.breakfast, description: 'Protein shake', proteinGrams: 40, calories: 220, ); await notifier.logFood( mealType: MealType.lunch, description: 'Chicken salad', proteinGrams: 45.5, calories: 480, carbsGrams: 20, fatGrams: 15, ); expect(okBreakfast, isTrue); final state = container.read(nutritionControllerProvider).valueOrNull!; expect(state.foodLogs, hasLength(2)); expect(state.totalProteinGrams, closeTo(85.5, 1e-9)); expect(state.totalCalories, 700); expect(state.totalCarbsGrams, 20); expect(state.totalFatGrams, 15); expect(state.mealsByType.keys, [MealType.breakfast, MealType.lunch]); expect(state.proteinProgress, closeTo(0.855, 1e-9)); }); test('logFood rejects empty descriptions and negative macros', () async { await load(); final notifier = container.read(nutritionControllerProvider.notifier); expect( await notifier.logFood( mealType: MealType.snack, description: ' ', proteinGrams: 10, calories: 100, ), isFalse, ); expect( await notifier.logFood( mealType: MealType.snack, description: 'Bad', proteinGrams: -1, calories: 100, ), isFalse, ); expect( container.read(nutritionControllerProvider).valueOrNull!.foodLogs, isEmpty, ); }); test('quick-add water accumulates toward the 96 oz target', () async { await load(); final notifier = container.read(nutritionControllerProvider.notifier); await notifier.logWater(8); await notifier.logWater(16); await notifier.logWater(24); final state = container.read(nutritionControllerProvider).valueOrNull!; expect(state.totalWaterOz, 48); expect(state.hydrationLogs, hasLength(3)); expect(state.hydrationProgress, 0.5); expect(await notifier.logWater(0), isFalse); }); test('deleteFood and deleteWater remove entries and refresh totals', () async { await load(); final notifier = container.read(nutritionControllerProvider.notifier); await notifier.logFood( mealType: MealType.dinner, description: 'Tofu stir fry', proteinGrams: 25, calories: 300, ); await notifier.logWater(16); var state = container.read(nutritionControllerProvider).valueOrNull!; final foodId = state.foodLogs.single.id!; final waterId = state.hydrationLogs.single.id!; await notifier.deleteFood(foodId); await notifier.deleteWater(waterId); state = container.read(nutritionControllerProvider).valueOrNull!; expect(state.foodLogs, isEmpty); expect(state.hydrationLogs, isEmpty); expect(state.totalProteinGrams, 0); expect(state.totalWaterOz, 0); }); test('failed writes keep the previous state and report false', () async { await load(); final notifier = container.read(nutritionControllerProvider.notifier); await notifier.logWater(8); repository.failNextSave = true; final ok = await notifier.logWater(16); expect(ok, isFalse); final state = container.read(nutritionControllerProvider).valueOrNull!; // First entry survives; the rejected write was not applied. expect(state.hydrationLogs, hasLength(1)); expect(state.totalWaterOz, 8); }); }); }