Initial commit: CookIT recipe collection and week planner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7d7a0bc60b
173 files changed
+22803
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import 'package:cookit/src/planning/day.dart';
|
||||
import 'package:cookit/src/planning/day_format.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() => initializeDateFormatting('de'));
|
||||
|
||||
final friday = DateTime.utc(2026, 10, 23);
|
||||
|
||||
test('weekStartOf honours the configured first weekday', () {
|
||||
expect(weekStartOf(friday, DateTime.monday), DateTime.utc(2026, 10, 19));
|
||||
expect(weekStartOf(friday, DateTime.tuesday), DateTime.utc(2026, 10, 20));
|
||||
expect(weekStartOf(friday, DateTime.friday), friday);
|
||||
expect(weekStartOf(friday, DateTime.saturday), DateTime.utc(2026, 10, 17));
|
||||
});
|
||||
|
||||
test('days survive daylight saving time and storage', () {
|
||||
// Summer time ends in Germany on 25 Oct 2026.
|
||||
expect(addDays(DateTime.utc(2026, 10, 24), 2), DateTime.utc(2026, 10, 26));
|
||||
expect(weekDays(DateTime.utc(2026, 10, 26)).last, DateTime.utc(2026, 11, 1));
|
||||
expect(dayKey(friday), '2026-10-23');
|
||||
expect(parseDayKey('2026-10-23'), friday);
|
||||
expect(dayOf(DateTime(2026, 10, 23, 23, 59)), friday);
|
||||
});
|
||||
|
||||
test('calendarWeek is the ISO week, also across the turn of the year', () {
|
||||
expect(calendarWeek(DateTime.utc(2026, 10, 19)), 43);
|
||||
expect(calendarWeek(DateTime.utc(2026, 10, 20)), 43); // a Tuesday-start week
|
||||
expect(calendarWeek(DateTime.utc(2026, 12, 28)), 53);
|
||||
expect(calendarWeek(DateTime.utc(2027, 1, 4)), 1);
|
||||
});
|
||||
|
||||
test('dayRange', () {
|
||||
expect(dayRange(DateTime.utc(2026, 10, 19), DateTime.utc(2026, 10, 25)), '19.–25. Okt.');
|
||||
expect(dayRange(DateTime.utc(2026, 10, 26), DateTime.utc(2026, 11, 1)), '26. Okt. – 1. Nov.');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:cookit/src/planning/ingredient_totals.dart';
|
||||
import 'package:cookit/src/recipes/models/recipe.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
|
||||
void main() {
|
||||
Recipe withLines(String id, List<IngredientLine> lines) => recipe(
|
||||
id: id,
|
||||
ingredientGroups: [IngredientGroup(lines: lines)],
|
||||
);
|
||||
|
||||
test('lines with the same name and Unit are added up, others stay apart', () {
|
||||
final totals = ingredientTotals([
|
||||
withLines('a', const [
|
||||
IngredientLine(amount: 200, unitKey: 'gram', name: 'Butter'),
|
||||
IngredientLine(amount: 2, name: 'Eier'),
|
||||
IngredientLine(name: 'Salz'),
|
||||
]),
|
||||
withLines('b', const [
|
||||
IngredientLine(amount: 200, unitKey: 'gram', name: 'butter '),
|
||||
IngredientLine(amount: 1, unitKey: 'tablespoon', name: 'Butter'),
|
||||
IngredientLine(amount: 1, amountMax: 2, name: 'Eier'),
|
||||
IngredientLine(name: 'Salz'),
|
||||
]),
|
||||
]);
|
||||
expect(
|
||||
[for (final l in totals) (l.amount, l.amountMax, l.unitKey, l.name)],
|
||||
[
|
||||
(400.0, null, 'gram', 'Butter'),
|
||||
(3.0, 4.0, null, 'Eier'),
|
||||
(null, null, null, 'Salz'),
|
||||
(1.0, null, 'tablespoon', 'Butter'),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a remark survives only when every line agrees', () {
|
||||
final totals = ingredientTotals([
|
||||
withLines('a', const [
|
||||
IngredientLine(amount: 1, name: 'Zitrone', remark: 'bio'),
|
||||
IngredientLine(amount: 1, name: 'Ei', remark: 'groß'),
|
||||
]),
|
||||
withLines('b', const [
|
||||
IngredientLine(amount: 1, name: 'Zitrone', remark: 'bio'),
|
||||
IngredientLine(amount: 1, name: 'Ei'),
|
||||
]),
|
||||
]);
|
||||
expect([for (final l in totals) l.remark], ['bio', null]);
|
||||
});
|
||||
|
||||
test('the same Recipe planned twice counts twice', () {
|
||||
final r = withLines('a', const [IngredientLine(amount: 100, unitKey: 'gram', name: 'Mehl')]);
|
||||
expect(ingredientTotals([r, r]).single.amount, 200);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cookit/src/database/database.dart';
|
||||
import 'package:cookit/src/planning/models/meal.dart';
|
||||
import 'package:cookit/src/planning/plan_repository.dart';
|
||||
import 'package:cookit/src/recipes/photo_store.dart';
|
||||
import 'package:cookit/src/recipes/recipe_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../support/test_database.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late PhotoStore photos;
|
||||
late PlanRepository plan;
|
||||
late RecipeRepository recipes;
|
||||
|
||||
final yesterday = DateTime.utc(2026, 10, 22);
|
||||
final today = DateTime.utc(2026, 10, 23);
|
||||
final tomorrow = DateTime.utc(2026, 10, 24);
|
||||
|
||||
setUp(() async {
|
||||
db = testDatabase();
|
||||
photos = await tempPhotoStore();
|
||||
plan = PlanRepository(db);
|
||||
recipes = RecipeRepository(db, photos);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
await photos.directory.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<Meal> meal(String name) async => (await plan.allMeals()).firstWhere((m) => m.name == name);
|
||||
|
||||
test('a fresh database plans Mittag and Abend', () async {
|
||||
expect((await plan.allMeals()).map((m) => m.name), defaultMeals);
|
||||
});
|
||||
|
||||
test('entries are added in order, moved to the end of another Meal and removed', () async {
|
||||
await recipes.saveRecipe(recipe());
|
||||
final abend = await meal('Abend');
|
||||
final mittag = await meal('Mittag');
|
||||
await plan.addEntry(today, abend.id, recipeId: 'r1');
|
||||
await plan.addEntry(today, abend.id, note: 'Salat');
|
||||
await plan.addEntry(tomorrow, mittag.id, note: 'Reste');
|
||||
|
||||
var entries = await plan.allEntries();
|
||||
expect(
|
||||
[for (final e in entries) (e.day, e.recipeId ?? e.note, e.position)],
|
||||
[(today, 'r1', 0), (today, 'Salat', 1), (tomorrow, 'Reste', 0)],
|
||||
);
|
||||
|
||||
await plan.moveEntry(entries.first.id, tomorrow, mittag.id);
|
||||
await plan.removeEntry(entries[1].id);
|
||||
entries = await plan.allEntries();
|
||||
expect(
|
||||
[for (final e in entries) (e.day, e.mealId, e.recipeId ?? e.note, e.position)],
|
||||
[(tomorrow, mittag.id, 'Reste', 0), (tomorrow, mittag.id, 'r1', 1)],
|
||||
);
|
||||
});
|
||||
|
||||
test('deleting a Meal removes its upcoming entries and keeps it retired for the past', () async {
|
||||
final abend = await meal('Abend');
|
||||
await plan.addEntry(yesterday, abend.id, note: 'Pizza');
|
||||
await plan.addEntry(today, abend.id, note: 'Suppe');
|
||||
await plan.addEntry(tomorrow, abend.id, note: 'Curry');
|
||||
expect(await plan.upcomingEntryCount(abend.id, today), 2);
|
||||
|
||||
await plan.deleteMeal(abend.id, today);
|
||||
expect((await plan.allEntries()).map((e) => e.note), ['Pizza']);
|
||||
expect((await meal('Abend')).retired, isTrue);
|
||||
|
||||
// Added again under the same name, the old Meal comes back with its history.
|
||||
await plan.addMeal('abend');
|
||||
final back = await meal('abend');
|
||||
expect(back.id, abend.id);
|
||||
expect(back.retired, isFalse);
|
||||
});
|
||||
|
||||
test('deleting an unused Meal removes it entirely', () async {
|
||||
final mittag = await meal('Mittag');
|
||||
await plan.deleteMeal(mittag.id, today);
|
||||
expect((await plan.allMeals()).map((m) => m.name), ['Abend']);
|
||||
});
|
||||
|
||||
test('a deleted Recipe stays in the plan as a note with its name', () async {
|
||||
await recipes.saveRecipe(recipe());
|
||||
final abend = await meal('Abend');
|
||||
await plan.addEntry(yesterday, abend.id, recipeId: 'r1');
|
||||
await recipes.deleteRecipe(recipe());
|
||||
final entry = (await plan.allEntries()).single;
|
||||
expect(entry.recipeId, isNull);
|
||||
expect(entry.note, 'Focaccia');
|
||||
});
|
||||
|
||||
test('"Tauschen" plans another Recipe in the same entry', () async {
|
||||
await recipes.saveRecipe(recipe());
|
||||
await recipes.saveRecipe(recipe(id: 'r2', name: 'Pizza'));
|
||||
final abend = await meal('Abend');
|
||||
await plan.addEntry(today, abend.id, recipeId: 'r1');
|
||||
await plan.replaceRecipe((await plan.allEntries()).single.id, 'r2');
|
||||
expect((await plan.allEntries()).single.recipeId, 'r2');
|
||||
});
|
||||
|
||||
test('an existing version 2 database gets the Week Plan tables on upgrade', () async {
|
||||
await db.close();
|
||||
final dir = await Directory.systemTemp.createTemp('cookit_db_');
|
||||
final file = File('${dir.path}/cookit.sqlite');
|
||||
|
||||
var fileDb = testDatabase(file: file);
|
||||
await RecipeRepository(fileDb, photos).saveRecipe(recipe());
|
||||
await fileDb.customStatement('DROP TABLE plan_entries');
|
||||
await fileDb.customStatement('DROP TABLE meals');
|
||||
await fileDb.customStatement('PRAGMA user_version = 2');
|
||||
await fileDb.close();
|
||||
|
||||
fileDb = testDatabase(file: file);
|
||||
expect((await PlanRepository(fileDb).allMeals()).map((m) => m.name), defaultMeals);
|
||||
expect((await RecipeRepository(fileDb, photos).getRecipe('r1'))!.name, 'Focaccia');
|
||||
await fileDb.close();
|
||||
await dir.delete(recursive: true);
|
||||
db = testDatabase(); // for tearDown
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:cookit/src/bundle/bundle_service.dart';
|
||||
import 'package:cookit/src/database/database.dart';
|
||||
import 'package:cookit/src/planning/day.dart';
|
||||
import 'package:cookit/src/planning/plan_repository.dart';
|
||||
import 'package:cookit/src/planning/plan_share_service.dart';
|
||||
import 'package:cookit/src/recipes/course_repository.dart';
|
||||
import 'package:cookit/src/recipes/photo_store.dart';
|
||||
import 'package:cookit/src/recipes/recipe_repository.dart';
|
||||
import 'package:cookit/src/recipes/unit_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../support/test_database.dart';
|
||||
|
||||
/// One cook's collection and Week Plan.
|
||||
class Phone {
|
||||
Phone._(this.db, this.photos)
|
||||
: recipes = RecipeRepository(db, photos),
|
||||
courses = CourseRepository(db),
|
||||
units = UnitRepository(db),
|
||||
plan = PlanRepository(db) {
|
||||
bundles = BundleService(recipes, courses, units, photos);
|
||||
shares = PlanShareService(recipes, courses, units, photos, plan, bundles);
|
||||
}
|
||||
|
||||
static Future<Phone> create() async {
|
||||
final phone = Phone._(testDatabase(), await tempPhotoStore());
|
||||
addTearDown(() async {
|
||||
await phone.db.close();
|
||||
await phone.photos.directory.delete(recursive: true);
|
||||
});
|
||||
return phone;
|
||||
}
|
||||
|
||||
final AppDatabase db;
|
||||
final PhotoStore photos;
|
||||
final RecipeRepository recipes;
|
||||
final CourseRepository courses;
|
||||
final UnitRepository units;
|
||||
final PlanRepository plan;
|
||||
late final BundleService bundles;
|
||||
late final PlanShareService shares;
|
||||
|
||||
Future<String> meal(String name) async => (await plan.allMeals()).firstWhere((m) => m.name == name).id;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final monday = DateTime.utc(2026, 10, 19);
|
||||
|
||||
test('a shared week reuses known Recipes, adds new ones and its Meals, and keeps what is planned', () async {
|
||||
final mine = await Phone.create();
|
||||
final theirs = await Phone.create();
|
||||
|
||||
// Mine: three Recipes planned across the week, plus a note and a Meal they don't have.
|
||||
await mine.recipes.saveRecipe(recipe(id: 'shared', name: 'Focaccia'));
|
||||
await mine.recipes.saveRecipe(recipe(id: 'mine-pizza', name: 'Pizza'));
|
||||
await mine.recipes.saveRecipe(recipe(id: 'new', name: 'Gulasch'));
|
||||
await mine.plan.addMeal('Frühstück');
|
||||
await mine.plan.addEntry(monday, await mine.meal('Abend'), recipeId: 'shared');
|
||||
await mine.plan.addEntry(addDays(monday, 1), await mine.meal('Abend'), recipeId: 'mine-pizza');
|
||||
await mine.plan.addEntry(addDays(monday, 2), await mine.meal('Mittag'), recipeId: 'new');
|
||||
await mine.plan.addEntry(addDays(monday, 3), await mine.meal('Frühstück'), note: 'Bäcker');
|
||||
await mine.plan.addEntry(addDays(monday, 8), await mine.meal('Abend'), recipeId: 'new'); // next week: not sent
|
||||
|
||||
// Theirs: the same Focaccia (same id), their own Pizza (same name), and a plan of their own.
|
||||
await theirs.recipes.saveRecipe(recipe(id: 'shared', name: 'Focaccia'));
|
||||
await theirs.recipes.saveRecipe(recipe(id: 'their-pizza', name: 'pizza '));
|
||||
await theirs.plan.addEntry(monday, await theirs.meal('Mittag'), note: 'Reste');
|
||||
|
||||
final week = SharedWeek(await theirs.bundles.read(await mine.shares.weekFile(monday)));
|
||||
final preview = await theirs.shares.preview(week);
|
||||
expect((preview.entries, preview.newRecipes, preview.existingEntries), (4, 1, 1));
|
||||
|
||||
await theirs.shares.adopt(week);
|
||||
|
||||
final recipes = await theirs.recipes.allRecipes();
|
||||
expect(recipes.map((r) => r.id), unorderedEquals(['shared', 'their-pizza', 'new']));
|
||||
final meals = {for (final m in await theirs.plan.allMeals()) m.id: m.name};
|
||||
final entries = await theirs.plan.allEntries();
|
||||
expect(
|
||||
[for (final e in entries) (e.day, meals[e.mealId], e.recipeId ?? e.note)],
|
||||
unorderedEquals([
|
||||
(monday, 'Mittag', 'Reste'),
|
||||
(monday, 'Abend', 'shared'),
|
||||
(addDays(monday, 1), 'Abend', 'their-pizza'),
|
||||
(addDays(monday, 2), 'Mittag', 'new'),
|
||||
(addDays(monday, 3), 'Frühstück', 'Bäcker'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('only a bundle shared from the Week Plan carries a week', () async {
|
||||
final mine = await Phone.create();
|
||||
await mine.recipes.saveRecipe(recipe());
|
||||
final contents = await mine.bundles.read(await mine.bundles.exportBundle());
|
||||
expect(contents.plan, isNull);
|
||||
final week = await mine.bundles.read(await mine.shares.weekFile(monday));
|
||||
expect(week.plan, isNotNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cookit/src/planning/week_fill.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
|
||||
void main() {
|
||||
final days = [for (var d = 19; d <= 25; d++) DateTime.utc(2026, 10, d)];
|
||||
final recipes = [for (var i = 0; i < 5; i++) recipe(id: 'r$i', name: 'R$i')];
|
||||
|
||||
test('fills each empty day with a different Recipe, never one already in the week', () {
|
||||
final filled = fillWeek(
|
||||
emptyDays: days.take(3).toList(),
|
||||
candidates: recipes,
|
||||
inWeek: {'r0'},
|
||||
recent: const {},
|
||||
random: Random(1),
|
||||
);
|
||||
expect(filled.keys, days.take(3));
|
||||
final ids = filled.values.map((r) => r.id).toList();
|
||||
expect(ids.toSet(), hasLength(3));
|
||||
expect(ids, isNot(contains('r0')));
|
||||
});
|
||||
|
||||
test('recently planned Recipes come only after the others', () {
|
||||
for (var seed = 0; seed < 20; seed++) {
|
||||
final filled = fillWeek(
|
||||
emptyDays: days.take(4).toList(),
|
||||
candidates: recipes,
|
||||
inWeek: const {},
|
||||
recent: {'r0', 'r1'},
|
||||
random: Random(seed),
|
||||
);
|
||||
final ids = filled.values.map((r) => r.id).toList();
|
||||
expect(ids.take(3).toSet(), {'r2', 'r3', 'r4'});
|
||||
expect(ids.last, anyOf('r0', 'r1'));
|
||||
}
|
||||
});
|
||||
|
||||
test('days stay empty when the Recipes run out', () {
|
||||
final filled = fillWeek(
|
||||
emptyDays: days,
|
||||
candidates: recipes.take(2).toList(),
|
||||
inWeek: const {},
|
||||
recent: const {},
|
||||
random: Random(2),
|
||||
);
|
||||
expect(filled, hasLength(2));
|
||||
});
|
||||
|
||||
test('pickReplacement skips the week and returns null when nothing is left', () {
|
||||
final pick = pickReplacement(
|
||||
candidates: recipes,
|
||||
inWeek: {'r0', 'r1', 'r2', 'r3'},
|
||||
recent: const {},
|
||||
random: Random(),
|
||||
);
|
||||
expect(pick?.id, 'r4');
|
||||
expect(
|
||||
pickReplacement(candidates: recipes, inWeek: {for (final r in recipes) r.id}, recent: const {}, random: Random()),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user