364 lines
12 KiB
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.');
|
|
});
|
|
});
|
|
}
|