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 = >[]; final subscription = recipeRepository.watchRecipes().listen(events.add); await recipeRepository.saveRecipe(makeRecipe()); await Future.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.'); }); }); }