387 lines
14 KiB
Dart
387 lines
14 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/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<RecipeIngredient> 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);
|
||
});
|
||
});
|
||
} |