glp-companion/test/features/meal_planner/meal_plan_repository_test.dart

364 lines
12 KiB
Dart

import 'package:drift/native.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/core/database/app_database.dart';
import 'package:glp1_companion/core/utils/date_time_utils.dart';
import 'package:glp1_companion/core/utils/uuid.dart';
import 'package:glp1_companion/features/meal_planner/data/repositories/drift_meal_plan_repository.dart';
import 'package:glp1_companion/features/meal_planner/data/repositories/drift_recipe_repository.dart';
import 'package:glp1_companion/features/meal_planner/domain/entities/macro_profile.dart';
import 'package:glp1_companion/features/meal_planner/domain/entities/meal_plan_slot.dart';
import 'package:glp1_companion/features/meal_planner/domain/entities/recipe.dart';
import 'package:glp1_companion/features/meal_planner/domain/entities/recipe_ingredient.dart';
import 'package:glp1_companion/features/meal_planner/presentation/controllers/meal_plan_calendar_controller.dart';
import 'package:glp1_companion/features/meal_planner/presentation/controllers/recipe_import_controller.dart';
import 'package:glp1_companion/features/nutrition/domain/entities/meal_type.dart';
void main() {
late AppDatabase database;
late DriftRecipeRepository recipeRepository;
late DriftMealPlanRepository mealPlanRepository;
late ProviderContainer container;
final weekMonday = DateTime(2026, 10, 12); // A Monday.
final tuesday = DateTime(2026, 10, 13);
Recipe makeRecipe({
String? id,
String title = 'Greek Yogurt Power Bowl',
String sourceUrl = 'https://recipes.test/yogurt-bowl',
double proteinGrams = 30,
}) =>
Recipe(
id: id ?? generateUuid(),
title: title,
sourceUrl: sourceUrl,
imageUrl: 'https://recipes.test/bowl.jpg',
description: 'High-protein breakfast.',
prepTimeMinutes: 10,
cookTimeMinutes: 0,
servings: 2,
ingredients: const [
RecipeIngredient(
rawText: '1 cup greek yogurt',
parsedItemName: 'greek yogurt',
quantity: 1,
unit: 'cup',
),
],
instructions: const ['Scoop, top, and eat.'],
macrosPerServing: MacroProfile(
calories: 320,
proteinGrams: proteinGrams,
carbsGrams: 24,
fatGrams: 9,
),
);
setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
recipeRepository = DriftRecipeRepository(database);
mealPlanRepository = DriftMealPlanRepository(database);
container = ProviderContainer(
overrides: [
appDatabaseProvider.overrideWithValue(database),
],
);
});
tearDown(() {
container.dispose();
database.close();
});
group('startOfWeek', () {
test('snaps any day back to its Monday', () {
expect(startOfWeek(DateTime(2026, 10, 16, 14, 30)), weekMonday);
expect(startOfWeek(weekMonday), weekMonday);
expect(startOfWeek(DateTime(2026, 10, 18, 23, 59)), weekMonday);
});
});
group('DriftRecipeRepository', () {
test('saveRecipe round-trips all entity fields', () async {
final saved = await recipeRepository.saveRecipe(makeRecipe());
final fetched = await recipeRepository.getRecipe(saved.id);
expect(fetched, isNotNull);
expect(fetched!.title, 'Greek Yogurt Power Bowl');
expect(fetched.imageUrl, 'https://recipes.test/bowl.jpg');
expect(fetched.description, 'High-protein breakfast.');
expect(fetched.prepTimeMinutes, 10);
expect(fetched.cookTimeMinutes, 0);
expect(fetched.servings, 2);
expect(fetched.ingredients, hasLength(1));
expect(fetched.ingredients.single.unit, 'cup');
expect(fetched.instructions, ['Scoop, top, and eat.']);
expect(fetched.macrosPerServing.proteinGrams, 30);
expect(fetched.macrosPerServing.calories, 320);
});
test('importRecipe de-duplicates by source URL', () async {
final first = await recipeRepository
.importRecipe(makeRecipe(title: 'Version One'));
final second = await recipeRepository.importRecipe(
makeRecipe(title: 'Version Two'),
);
expect(second.id, first.id);
final catalog = await recipeRepository.getRecipes();
expect(catalog, hasLength(1));
expect(catalog.single.title, 'Version Two');
});
test('watchRecipes emits on every write', () async {
final events = <List<Recipe>>[];
final subscription = recipeRepository.watchRecipes().listen(events.add);
await recipeRepository.saveRecipe(makeRecipe());
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(events, isNotEmpty);
expect(events.last, hasLength(1));
await subscription.cancel();
});
});
group('DriftMealPlanRepository', () {
test('slots persist within the half-open window only', () async {
final recipe = await recipeRepository.saveRecipe(makeRecipe());
await mealPlanRepository.saveSlot(
MealPlanSlot(
id: generateUuid(),
date: tuesday,
mealType: MealType.lunch,
recipeId: recipe.id,
),
);
await mealPlanRepository.saveSlot(
MealPlanSlot(
id: generateUuid(),
date: DateTime(2026, 10, 20), // Next week.
mealType: MealType.dinner,
customDescription: 'Outside the window',
),
);
final inWindow = await mealPlanRepository.getSlots(
from: weekMonday,
to: weekMonday.add(const Duration(days: 7)),
);
expect(inWindow, hasLength(1));
expect(inWindow.single.mealType, MealType.lunch);
expect(inWindow.single.recipeId, recipe.id);
});
test('saveSlot upserts by id and clearSlot deletes', () async {
final slotId = generateUuid();
await mealPlanRepository.saveSlot(
MealPlanSlot(
id: slotId,
date: tuesday,
mealType: MealType.dinner,
customDescription: 'Salmon & greens',
),
);
await mealPlanRepository.saveSlot(
MealPlanSlot(
id: slotId,
date: tuesday,
mealType: MealType.dinner,
customDescription: 'Salmon & greens',
isPrepared: true,
),
);
var slots = await mealPlanRepository.getSlots(
from: weekMonday,
to: weekMonday.add(const Duration(days: 7)),
);
expect(slots, hasLength(1));
expect(slots.single.isPrepared, isTrue);
await mealPlanRepository.deleteSlot(slotId);
slots = await mealPlanRepository.getSlots(
from: weekMonday,
to: weekMonday.add(const Duration(days: 7)),
);
expect(slots, isEmpty);
});
});
group('MealPlanCalendarController', () {
test('window starts on Monday and slots + protein roll up per day',
() async {
final recipeA = await recipeRepository.saveRecipe(
makeRecipe(title: 'A', sourceUrl: 'https://recipes.test/a'),
);
final recipeB = await recipeRepository.saveRecipe(
makeRecipe(
title: 'B',
sourceUrl: 'https://recipes.test/b',
proteinGrams: 25,
),
);
final controller =
container.read(mealPlanCalendarControllerProvider.notifier);
await controller.refresh();
final start =
container.read(mealPlanCalendarControllerProvider).windowStart;
expect(start.weekday, DateTime.monday);
expect(start.hour, 0);
await controller.assignRecipe(
day: start,
mealType: MealType.breakfast,
recipeId: recipeA.id,
);
await controller.assignRecipe(
day: tuesday,
mealType: MealType.dinner,
recipeId: recipeB.id,
);
final state = container.read(mealPlanCalendarControllerProvider);
expect(state.slots, hasLength(2));
expect(state.proteinForDay(start), 30);
expect(state.proteinForDay(tuesday), 25);
expect(state.proteinForDay(DateTime(2026, 10, 14)), 0);
expect(state.slotFor(tuesday, MealType.dinner)?.recipeId, recipeB.id);
expect(state.slotFor(tuesday, MealType.breakfast), isNull);
});
test('assigning to an occupied slot reuses its row id', () async {
final recipeA = await recipeRepository.saveRecipe(
makeRecipe(title: 'A', sourceUrl: 'https://recipes.test/a'),
);
final recipeB = await recipeRepository.saveRecipe(
makeRecipe(title: 'B', sourceUrl: 'https://recipes.test/b'),
);
final controller =
container.read(mealPlanCalendarControllerProvider.notifier);
await controller.refresh();
await controller.assignRecipe(
day: weekMonday,
mealType: MealType.lunch,
recipeId: recipeA.id,
);
final firstId = container
.read(mealPlanCalendarControllerProvider)
.slotFor(weekMonday, MealType.lunch)!
.id;
await controller.assignRecipe(
day: weekMonday,
mealType: MealType.lunch,
recipeId: recipeB.id,
);
final updated = container
.read(mealPlanCalendarControllerProvider)
.slotFor(weekMonday, MealType.lunch)!;
expect(updated.id, firstId);
expect(updated.recipeId, recipeB.id);
});
test('deleting a recipe clears its assigned slots on refresh', () async {
final recipe = await recipeRepository.saveRecipe(makeRecipe());
final controller =
container.read(mealPlanCalendarControllerProvider.notifier);
await controller.refresh();
await controller.assignRecipe(
day: weekMonday,
mealType: MealType.breakfast,
recipeId: recipe.id,
);
await recipeRepository.deleteRecipe(recipe.id);
await controller.refresh();
final state = container.read(mealPlanCalendarControllerProvider);
expect(state.slotFor(weekMonday, MealType.breakfast)?.recipeId, isNull);
});
test('nextWeek/previousWeek/goToToday move the window', () async {
final controller =
container.read(mealPlanCalendarControllerProvider.notifier);
await controller.refresh();
final initial =
container.read(mealPlanCalendarControllerProvider).windowStart;
await controller.nextWeek();
expect(
container.read(mealPlanCalendarControllerProvider).windowStart,
initial.add(const Duration(days: 7)),
);
await controller.previousWeek();
expect(
container.read(mealPlanCalendarControllerProvider).windowStart,
initial,
);
await controller.nextWeek();
await controller.goToToday();
expect(
container.read(mealPlanCalendarControllerProvider).windowStart,
startOfWeek(DateTime.now()),
);
});
test('custom entries and prepared toggles persist', () async {
final controller =
container.read(mealPlanCalendarControllerProvider.notifier);
await controller.refresh();
await controller.saveCustomEntry(
day: weekMonday,
mealType: MealType.snack,
description: 'Cottage cheese & pineapple',
);
var slot = container
.read(mealPlanCalendarControllerProvider)
.slotFor(weekMonday, MealType.snack)!;
expect(slot.customDescription, 'Cottage cheese & pineapple');
expect(slot.recipeId, isNull);
expect(slot.isEmpty, isFalse);
await controller.togglePrepared(slot);
slot = container
.read(mealPlanCalendarControllerProvider)
.slotFor(weekMonday, MealType.snack)!;
expect(slot.isPrepared, isTrue);
await controller.clearSlot(slot);
expect(
container
.read(mealPlanCalendarControllerProvider)
.slotFor(weekMonday, MealType.snack),
isNull,
);
});
});
group('RecipeImportController', () {
test('submitUrl with an empty URL sets an error', () async {
// Force construction of the notifier (build() subscribes to the
// recipe watch stream).
final importController =
container.read(recipeImportControllerProvider.notifier);
await importController.submitUrl(' ');
final state = container.read(recipeImportControllerProvider);
expect(state.isImporting, isFalse);
expect(state.errorMessage, 'Paste a recipe URL first.');
});
});
}