glp-companion/test/features/pantry/delta_calculator_test.dart

531 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});
}