291 lines
8.8 KiB
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);
|
|
});
|
|
});
|
|
} |