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('CalculateShoppingDelta — unit conversions', () { test('aggregates kg and g onto one scale, reporting in the dominant unit', () async { await repository.saveItem( makeStock(name: 'beef mince', quantity: 300, unit: 'g'), ); final metricRecipe = makeRecipe( title: 'Bolognese', ingredients: [ ingredient('beef mince', 1.25, 'kg', rawText: '1.25 kg beef mince'), ], ); final gramRecipe = makeRecipe( title: 'Tacos', ingredients: [ingredient('beef mince', 500, 'g', rawText: '500 g beef mince')], ); await deltaEngine( slots: [ makeSlot(day: weekMonday, recipeId: metricRecipe.id), makeSlot( day: weekMonday.add(const Duration(days: 1)), recipeId: gramRecipe.id, ), ], recipes: [metricRecipe, gramRecipe], ); final entry = (await repository.getShoppingList()) .singleWhere((item) => item.name == 'beef mince'); // 1.25 kg + 500 g − 300 g = 1450 g, reported in the dominant unit kg. expect(entry.unit, 'kg'); expect(entry.requiredQuantity, closeTo(1.45, 0.001)); }); test('unifies l and ml volumes before computing the deficit', () async { await repository.saveItem( makeStock(name: 'vegetable stock', quantity: 250, unit: 'ml'), ); final litreRecipe = makeRecipe( title: 'Risotto', ingredients: [ ingredient('vegetable stock', 1, 'l', rawText: '1 l vegetable stock'), ], ); final mlRecipe = makeRecipe( title: 'Soup', ingredients: [ ingredient('vegetable stock', 500, 'ml', rawText: '500 ml vegetable stock'), ], ); await deltaEngine( slots: [ makeSlot(day: weekMonday, recipeId: litreRecipe.id), makeSlot( day: weekMonday.add(const Duration(days: 2)), recipeId: mlRecipe.id, ), ], recipes: [litreRecipe, mlRecipe], ); final entry = (await repository.getShoppingList()) .singleWhere((item) => item.name == 'vegetable stock'); // 1 l + 500 ml − 250 ml = 1250 ml, reported in the dominant unit l. expect(entry.unit, 'l'); expect(entry.requiredQuantity, closeTo(1.25, 0.001)); }); test('a mass requirement never cancels against volume stock', () async { await repository.saveItem( makeStock(name: 'broth', quantity: 100, unit: 'ml'), ); final recipe = makeRecipe( title: 'Glaze', ingredients: [ ingredient('broth', 200, 'g', rawText: '200 g broth'), ingredient('broth', 300, 'ml', rawText: '300 ml broth'), ], ); await deltaEngine( slots: [makeSlot(day: weekMonday, recipeId: recipe.id)], recipes: [recipe], ); final list = await repository.getShoppingList(); expect(list, hasLength(1)); final entry = list.single; // The volume line is skipped (dimension mismatch with the mass // requirement) and the ml stock cannot offset a gram deficit. expect(entry.unit, 'g'); expect(entry.requiredQuantity, closeTo(200, 0.001)); }); test('unit synonyms and plurals fold onto one canonical scale', () async { // Pantry saved with a spelled-out plural unit still offsets grams. await repository.saveItem( makeStock(name: 'almond flour', quantity: 200, unit: 'grams'), ); final cupRecipe = makeRecipe( title: 'Muffins', ingredients: [ ingredient('almond flour', 1, 'cup', rawText: '1 cup almond flour'), ], ); final cupsRecipe = makeRecipe( title: 'Pancakes', ingredients: [ ingredient('almond flour', 2, 'cups', rawText: '2 cups almond flour'), ], ); final gramRecipe = makeRecipe( title: 'Cookies', ingredients: [ ingredient('almond flour', 300, 'g', rawText: '300 grams almond flour'), ], ); await deltaEngine( slots: [ makeSlot(day: weekMonday, recipeId: cupRecipe.id), makeSlot( day: weekMonday.add(const Duration(days: 1)), recipeId: cupsRecipe.id, ), makeSlot( day: weekMonday.add(const Duration(days: 2)), recipeId: gramRecipe.id, ), ], recipes: [cupRecipe, cupsRecipe, gramRecipe], ); final flour = (await repository.getShoppingList()) .singleWhere((item) => item.name == 'almond flour'); // 1 + 2 cups total: the 300 g mass line cannot join the volume // requirement, and the 200 g ("grams") pantry stock cannot offset it. expect(flour.unit, 'cup'); expect(flour.requiredQuantity, closeTo(3, 0.01)); }); }); 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); }); }); }