glp-companion/test/features/nutrition/presentation/nutrition_controller_test.dart

291 lines
8.8 KiB
Dart

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<FoodLogEntry> foods = [];
final List<HydrationLogEntry> 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<FoodLogEntry> _sortedFood(Iterable<FoodLogEntry> entries) {
final list = entries.toList()
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
return List.unmodifiable(list);
}
List<HydrationLogEntry> _sortedWater(Iterable<HydrationLogEntry> 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<List<FoodLogEntry>> watchFoodLogs({int limit = 500}) =>
Stream.value(_sortedFood(foods));
@override
Future<List<FoodLogEntry>> getFoodLogs({
int limit = 500,
DateTime? from,
DateTime? to,
}) async =>
_sortedFood(foods.where((e) => _inRange(e.timestamp, from, to)));
@override
Future<FoodLogEntry> 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<void> deleteFoodLog(int id) async {
foods.removeWhere((entry) => entry.id == id);
}
@override
Stream<List<HydrationLogEntry>> watchHydrationLogs({int limit = 500}) =>
Stream.value(_sortedWater(hydration));
@override
Future<List<HydrationLogEntry>> getHydrationLogs({
int limit = 500,
DateTime? from,
DateTime? to,
}) async =>
_sortedWater(hydration.where((e) => _inRange(e.timestamp, from, to)));
@override
Future<HydrationLogEntry> 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<void> 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<NutritionState> 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);
});
});
}