531 lines
18 KiB
Dart
531 lines
18 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('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);
|
||
});
|
||
});
|
||
} |