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/nutrition/domain/entities/meal_type.dart'; import 'package:glp1_companion/features/pantry/data/repositories/drift_pantry_repository.dart'; import 'package:glp1_companion/features/pantry/domain/entities/pantry_category.dart'; import 'package:glp1_companion/features/pantry/domain/entities/pantry_item.dart'; import 'package:glp1_companion/features/pantry/domain/entities/shopping_list_item.dart'; import 'package:glp1_companion/features/pantry/domain/usecases/calculate_shopping_delta.dart'; import 'package:glp1_companion/features/pantry/presentation/controllers/pantry_inventory_controller.dart'; import 'package:glp1_companion/features/pantry/presentation/controllers/shopping_list_controller.dart'; void main() { late AppDatabase database; late DriftPantryRepository repository; late CalculateShoppingDelta deltaEngine; late ProviderContainer container; /// A Monday (stable fixture for deterministic slot ordering). final weekMonday = DateTime(2026, 10, 12); PantryItem makeStock({ String? id, required String name, required double quantity, String unit = 'g', PantryCategory category = PantryCategory.other, }) => PantryItem( id: id ?? generateUuid(), name: name, quantityOnHand: quantity, unit: unit, category: category, lastUpdated: DateTime(2026, 10, 12, 9), ); Recipe makeRecipe({ required String title, required List ingredients, }) => Recipe( id: generateUuid(), title: title, sourceUrl: 'https://recipes.test/${generateUuid()}', servings: 4, ingredients: ingredients, instructions: const ['Cook it.'], macrosPerServing: const MacroProfile( calories: 400, proteinGrams: 40, carbsGrams: 20, fatGrams: 15, ), ); MealPlanSlot makeSlot({required DateTime day, required String recipeId}) => MealPlanSlot( id: generateUuid(), date: day, mealType: MealType.dinner, recipeId: recipeId, ); RecipeIngredient ingredient( String name, double quantity, String? unit, { String? rawText, }) => RecipeIngredient( rawText: rawText ?? '$quantity ${unit ?? ''} $name'.trim(), parsedItemName: name, quantity: quantity, unit: unit, ); setUp(() { database = AppDatabase.forTesting(NativeDatabase.memory()); repository = DriftPantryRepository(database); deltaEngine = CalculateShoppingDelta(repository); container = ProviderContainer( overrides: [appDatabaseProvider.overrideWithValue(database)], ); }); tearDown(() { container.dispose(); database.close(); }); group('CalculateShoppingDelta', () { test('300 g on hand vs an 800 g requirement generates a +500 g entry', () async { await repository.saveItem( makeStock(name: 'chicken breast', quantity: 300, unit: 'g'), ); final recipe = makeRecipe( title: 'Lemon Herb Chicken', ingredients: [ ingredient( 'chicken breast', 800, 'g', rawText: '800 g chicken breast', ), ], ); final result = await deltaEngine( slots: [makeSlot(day: weekMonday, recipeId: recipe.id)], recipes: [recipe], ); final list = await repository.getShoppingList(); expect(list, hasLength(1)); final entry = list.single; expect(entry.name, 'chicken breast'); expect(entry.requiredQuantity, 500); expect(entry.unit, 'g'); expect(entry.isAutoGenerated, isTrue); expect(entry.isPurchased, isFalse); expect(entry.sourceRecipeTitles, ['Lemon Herb Chicken']); expect(result.addedCount, 1); expect(result.updatedCount, 0); expect(result.removedItems, isEmpty); expect(result.summary, 'Sync complete: 1 added.'); }); test('sufficient stock generates no shopping entry', () async { await repository.saveItem( makeStock(name: 'chicken breast', quantity: 900, unit: 'g'), ); final recipe = makeRecipe( title: 'Lemon Herb Chicken', ingredients: [ingredient('chicken breast', 800, 'g')], ); final result = await deltaEngine( slots: [makeSlot(day: weekMonday, recipeId: recipe.id)], recipes: [recipe], ); expect(await repository.getShoppingList(), isEmpty); expect(result.addedCount, 0); expect(result.satisfiedNames, ['chicken breast']); expect( result.summary, "Your pantry already covers this week's meal plan.", ); }); test('manual shopping items are preserved when the sync runs', () async { final manual = ShoppingListItem( id: generateUuid(), name: 'Olive Oil', requiredQuantity: 1, unit: 'bottle', ); await repository.saveShoppingListItem(manual); await repository.saveItem( makeStock(name: 'chicken breast', quantity: 300, unit: 'g'), ); final recipe = makeRecipe( title: 'Lemon Herb Chicken', ingredients: [ingredient('chicken breast', 800, 'g')], ); final slots = [makeSlot(day: weekMonday, recipeId: recipe.id)]; await deltaEngine(slots: slots, recipes: [recipe]); // Re-running the sync must neither drop nor duplicate the manual row. await deltaEngine(slots: slots, recipes: [recipe]); final list = await repository.getShoppingList(); expect(list, hasLength(2)); final restoredManual = list.firstWhere((item) => item.id == manual.id); expect(restoredManual.name, 'Olive Oil'); expect(restoredManual.requiredQuantity, 1); expect(restoredManual.unit, 'bottle'); expect(restoredManual.isAutoGenerated, isFalse); expect(restoredManual.isPurchased, isFalse); expect(list.where((item) => item.isAutoGenerated), hasLength(1)); expect(list.where((item) => item.id == manual.id), hasLength(1)); }); test('stale auto entries are cleared once stock covers the plan', () async { final stock = makeStock(name: 'chicken breast', quantity: 300, unit: 'g'); await repository.saveItem(stock); final recipe = makeRecipe( title: 'Lemon Herb Chicken', ingredients: [ingredient('chicken breast', 800, 'g')], ); final slots = [makeSlot(day: weekMonday, recipeId: recipe.id)]; await deltaEngine(slots: slots, recipes: [recipe]); expect(await repository.getShoppingList(), hasLength(1)); await repository.saveItem(stock.copyWith(quantityOnHand: 900)); final result = await deltaEngine(slots: slots, recipes: [recipe]); expect(await repository.getShoppingList(), isEmpty); expect(result.removedItems, hasLength(1)); expect(result.clearedCount, 1); }); test('aggregates across recipes and normalizes plurals and units', () async { // Plural/missing-unit lines fold onto one canonical "egg" key: // 4 eggs + 2 egg − 2 in stock = a +4 "egg" deficit. await repository.saveItem( makeStock(name: 'egg', quantity: 2, unit: 'count'), ); final breakfast = makeRecipe( title: 'Baked Eggs', ingredients: [ingredient('eggs', 4, null, rawText: '4 eggs')], ); final brunch = makeRecipe( title: 'Sunday Omelette', ingredients: [ingredient('egg', 2, null, rawText: '2 egg')], ); final result = await deltaEngine( slots: [ makeSlot(day: weekMonday, recipeId: breakfast.id), makeSlot( day: weekMonday.add(const Duration(days: 1)), recipeId: brunch.id, ), ], recipes: [breakfast, brunch], ); final eggEntry = (await repository.getShoppingList()) .singleWhere((item) => item.name == 'egg'); expect(eggEntry.requiredQuantity, 4); expect(eggEntry.unit, 'count'); expect(eggEntry.sourceRecipeTitles, ['Baked Eggs', 'Sunday Omelette']); expect(result.addedCount, 1); }); test('converts lbs and oz onto one scale before computing the delta', () async { await repository.saveItem( makeStock(name: 'butter', quantity: 4, unit: 'oz'), ); final lbRecipe = makeRecipe( title: 'Cake Batter', ingredients: [ingredient('butter', 0.5, 'lb', rawText: '0.5 lb butter')], ); final ozRecipe = makeRecipe( title: 'Sauté Night', ingredients: [ingredient('butter', 8, 'oz', rawText: '8 oz butter')], ); await deltaEngine( slots: [ // Earlier date wins the dominant-unit tie (lb is aggregated first). makeSlot(day: weekMonday, recipeId: lbRecipe.id), makeSlot( day: weekMonday.add(const Duration(days: 1)), recipeId: ozRecipe.id, ), ], recipes: [lbRecipe, ozRecipe], ); final butter = (await repository.getShoppingList()) .singleWhere((item) => item.name == 'butter'); expect(butter.unit, 'lb'); // (0.5 lb + 8 oz − 4 oz) = 0.75 lb. expect(butter.requiredQuantity, closeTo(0.75, 0.001)); }); }); group('ShoppingListController', () { test('checking off with pantry transfer increments stock', () async { await repository.saveItem( makeStock(name: 'chicken breast', quantity: 300, unit: 'g'), ); final item = ShoppingListItem( id: generateUuid(), name: 'Chicken Breast', requiredQuantity: 500, unit: 'g', isAutoGenerated: true, sourceRecipeTitles: const ['Lemon Herb Chicken'], ); await repository.saveShoppingListItem(item); final controller = container.read(shoppingListControllerProvider.notifier); final ok = await controller.completeItem(item, addToPantry: true); expect(ok, isTrue); final stock = await repository.getItems(); expect(stock, hasLength(1)); expect(stock.single.quantityOnHand, 800); expect((await repository.getShoppingList()).single.isPurchased, isTrue); }); test('checking off a new item creates aisle-inferred pantry stock', () async { final item = ShoppingListItem( id: generateUuid(), name: 'Greek Yogurt', requiredQuantity: 2, unit: 'count', ); await repository.saveShoppingListItem(item); final controller = container.read(shoppingListControllerProvider.notifier); await controller.completeItem(item, addToPantry: true); final stock = await repository.getItems(); expect(stock.single.name, 'greek yogurt'); expect(stock.single.quantityOnHand, 2); expect(stock.single.unit, 'count'); expect(stock.single.category, PantryCategory.dairy); }); test('syncWithMealPlan wires the planner window into the delta engine', () async { final recipeRepository = DriftRecipeRepository(database); final mealPlanRepository = DriftMealPlanRepository(database); final recipe = makeRecipe( title: 'Lemon Herb Chicken', ingredients: [ingredient('chicken breast', 800, 'g')], ); await recipeRepository.saveRecipe(recipe); await mealPlanRepository.saveSlot( makeSlot(day: startOfWeek(DateTime.now()), recipeId: recipe.id), ); final controller = container.read(shoppingListControllerProvider.notifier); final summary = await controller.syncWithMealPlan(); expect(summary, isNotNull); expect( container.read(shoppingListControllerProvider).statusMessage, summary, ); final list = await repository.getShoppingList(); expect(list, hasLength(1)); expect(list.single.isAutoGenerated, isTrue); expect(list.single.requiredQuantity, 800); }); }); group('PantryInventoryController', () { test('addItem canonicalizes the name and quick adjust clamps at zero', () async { final controller = container.read(pantryInventoryControllerProvider.notifier); final saved = await controller.addItem( name: 'Chicken Breasts', quantityOnHand: 300, unit: 'g', category: PantryCategory.meatSeafood, ); expect(saved, isTrue); final stock = await repository.getItems(); expect(stock.single.name, 'chicken breast'); expect(stock.single.category, PantryCategory.meatSeafood); await controller.adjustQuantity(stock.single, -500); expect((await repository.getItems()).single.quantityOnHand, 0); await controller.adjustQuantity( (await repository.getItems()).single, 50, ); expect((await repository.getItems()).single.quantityOnHand, 50); }); }); }