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

387 lines
14 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('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);
});
});
}