Initial commit: CookIT recipe collection and week planner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 22:55:09 +01:00
commit 7d7a0bc60b
173 files changed
+22803

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:cookit/src/bundle/bundle_codec.dart';
import 'package:cookit/src/bundle/bundle_service.dart';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/recipes/course_repository.dart';
import 'package:cookit/src/recipes/models/recipe.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';
class _App {
_App(this.db, this.photos)
: recipes = RecipeRepository(db, photos),
courses = CourseRepository(db),
units = UnitRepository(db) {
bundles = BundleService(recipes, courses, units, photos, clock: () => testTime);
}
final AppDatabase db;
final PhotoStore photos;
final RecipeRepository recipes;
final CourseRepository courses;
final UnitRepository units;
late final BundleService bundles;
static Future<_App> create() async => _App(testDatabase(), await tempPhotoStore());
Future<void> close() async {
await db.close();
await photos.directory.delete(recursive: true);
}
}
Uint8List _zip(Map<String, List<int>> files) {
final archive = Archive();
files.forEach((name, bytes) => archive.add(ArchiveFile.bytes(name, bytes)));
return ZipEncoder().encodeBytes(archive);
}
void main() {
late _App source;
late _App target;
setUp(() async {
source = await _App.create();
target = await _App.create();
});
tearDown(() async {
await source.close();
await target.close();
});
test('export then import reproduces the collection with Photos and Courses', () async {
final course = await source.courses.addCourse('Suppe', emoji: '🍲');
final photo = await source.photos.saveBytes(Uint8List.fromList([9, 8, 7]));
await source.recipes.saveRecipe(recipe(courseId: course.id, photos: [photo], favourite: true));
await source.recipes.saveRecipe(recipe(id: 'r2', name: 'Brot'));
final bytes = await source.bundles.exportBundle();
final result = await target.bundles.importBundle(bytes);
expect((result.added, result.skipped), (2, 0));
final imported = (await target.recipes.watchRecipe('r1').first)!;
expect(imported.favourite, isTrue);
expect(imported.photos, hasLength(1));
expect(await target.photos.read(imported.photos.single), [9, 8, 7]);
final targetCourse = (await target.courses.allCourses()).firstWhere((c) => c.id == imported.courseId);
expect(targetCourse.name, 'Suppe');
expect(
[for (final g in imported.ingredientGroups) g.toJson()],
[for (final g in recipe().ingredientGroups) g.toJson()],
);
});
test('own units travel with the export and are created on import', () async {
final cube = await source.units.addOwn('Würfel');
await source.units.setDirect(cube.key, false);
await source.recipes.saveRecipe(
recipe(
ingredientGroups: [
IngredientGroup(
lines: [IngredientLine(amount: 2, unitKey: cube.key, name: 'Brühe')],
),
],
),
);
await target.bundles.importBundle(await source.bundles.exportBundle());
final imported = (await target.units.allUnits()).firstWhere((u) => u.key == cube.key);
expect((imported.label, imported.direct, imported.builtIn), ('Würfel', false, false));
final line = (await target.recipes.getRecipe('r1'))!.ingredientGroups.single.lines.single;
expect(line.unitKey, cube.key);
});
test('importing again skips Recipes that are already there', () async {
await source.recipes.saveRecipe(recipe());
final bytes = await source.bundles.exportBundle();
await target.bundles.importBundle(bytes);
final again = await target.bundles.importBundle(bytes);
expect((again.added, again.skipped), (0, 1));
expect(await target.recipes.allRecipes(), hasLength(1));
});
test('a Course named like an existing one is reused', () async {
final manifest = BundleCodec.encodeManifest([
BundleCodec.encodeRecipe(recipe(), courseName: 'Dessert', photoPaths: const []),
], createdAt: testTime);
await target.bundles.importBundle(_zip({'bundle.json': utf8.encode(jsonEncode(manifest))}));
final courses = await target.courses.allCourses();
expect(courses.where((c) => c.name == 'Dessert'), hasLength(1));
expect((await target.recipes.allRecipes()).single.courseId, courses.firstWhere((c) => c.name == 'Dessert').id);
});
test('rejects files that are not Recipe Bundles', () async {
expect(
() => target.bundles.importBundle(Uint8List.fromList(utf8.encode('hello'))),
throwsA(isA<BundleFormatException>()),
);
expect(
() => target.bundles.importBundle(
_zip({
'other.txt': [1],
}),
),
throwsA(isA<BundleFormatException>()),
);
expect(
() => target.bundles.importBundle(
_zip({
'bundle.json': utf8.encode(jsonEncode({'format': 'cookit.recipe-bundle', 'version': 99, 'recipes': []})),
}),
),
throwsA(isA<BundleFormatException>()),
);
});
// Contract test against the Excel converter's real output, when it has been generated.
final converted = File('tools/excel-import/out/cookit-rezepte.zip');
test(
'imports the converted Excel workbook',
() async {
final result = await target.bundles.importBundle(await converted.readAsBytes());
expect(result.added, greaterThan(200));
final all = await target.recipes.allRecipes();
final focaccia = all.firstWhere((r) => r.name == 'Focaccia');
expect(focaccia.stepSections.map((s) => s.title).take(2), ['Vorteig', 'Teig']);
final shake = all.firstWhere((r) => r.name == 'Melonen-Minze-Shake');
expect(shake.photos, hasLength(3));
expect(target.photos.file(shake.photos.first).existsSync(), isTrue);
expect(all.where((r) => r.needsReview), isNotEmpty);
},
skip: converted.existsSync() ? false : 'run tools/excel-import/convert.py first',
timeout: const Timeout(Duration(minutes: 2)),
);
}
@@ -0,0 +1,52 @@
import 'package:cookit/src/cooking_mode/cooking_session.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
void main() {
test('steps run through all Step Sections with continuous numbers', () {
final session = CookingSession(
recipe(
stepSections: const [
StepSection(steps: ['Ofen vorheizen.']),
StepSection(title: 'Teig', steps: ['Kneten.', 'Ruhen lassen.']),
],
),
);
expect(
[for (final s in session.steps) (s.number, s.section, s.text)],
[(1, null, 'Ofen vorheizen.'), (2, 'Teig', 'Kneten.'), (3, 'Teig', 'Ruhen lassen.')],
);
});
test('more servings scale every amount', () {
final session = CookingSession(recipe()); // 4 servings: 300 g Mehl, 2–3 EL Öl
expect(session.factor, 1);
session
..moreServings()
..moreServings();
expect(session.servings, 6);
final lines = session.ingredientGroups.first.lines;
expect(lines[0].amount, 450);
expect((lines[1].amount, lines[1].amountMax), (3, 4.5));
});
test('servings never go below one and fractional servings step to whole numbers', () {
final session = CookingSession(Recipe(id: 'x', name: 'x', servings: 2.5, createdAt: testTime, updatedAt: testTime));
session.fewerServings();
expect(session.servings, 2);
session
..fewerServings()
..fewerServings();
expect(session.servings, 1);
session.moreServings();
expect(session.servings, 2);
});
test('a Recipe without Servings is not scaled', () {
final session = CookingSession(Recipe(id: 'x', name: 'x', createdAt: testTime, updatedAt: testTime));
expect(session.canScale, isFalse);
expect(session.factor, 1);
});
}
+38
View File
@@ -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.');
});
}
+56
View File
@@ -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);
});
}
+126
View File
@@ -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
});
}
+100
View File
@@ -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);
});
}
+65
View File
@@ -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,
);
});
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:cookit/src/recipes/amount.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
setUpAll(() => initializeDateFormatting('de'));
group('parseAmount', () {
final cases = <String, Amount?>{
'': (amount: null, amountMax: null),
'2': (amount: 2, amountMax: null),
'1,5': (amount: 1.5, amountMax: null),
'0.25': (amount: 0.25, amountMax: null),
'½': (amount: 0.5, amountMax: null),
'1½': (amount: 1.5, amountMax: null),
'1/2': (amount: 0.5, amountMax: null),
'1 1/2': (amount: 1.5, amountMax: null),
'2-3': (amount: 2, amountMax: 3),
'10 – 12': (amount: 10, amountMax: 12),
'1 bis 2': (amount: 1, amountMax: 2),
'etwas': null,
'2-': null,
};
cases.forEach((input, expected) {
test('"$input"', () => expect(parseAmount(input), expected));
});
});
group('formatAmount', () {
test('uses fraction glyphs and German decimals', () {
expect(formatAmount(1.5, null), '1½');
expect(formatAmount(0.25, null), '¼');
expect(formatAmount(0.333, null), '⅓');
expect(formatAmount(2, null), '2');
expect(formatAmount(1.4, null), '1,4');
expect(formatAmount(1000, null), '1.000');
expect(formatAmount(2, 3), '2–3');
expect(formatAmount(null, null), '');
});
test('round-trips through parseAmount', () {
for (final value in [0.5, 1.5, 2.0, 1.4, 250.0]) {
expect(parseAmount(formatAmount(value, null))!.amount, closeTo(value, 0.001));
}
});
});
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:cookit/src/recipes/decision.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
void main() {
Recipe make(String id, {String? course, int? prep, int? cook}) => Recipe(
id: id,
name: id,
courseId: course,
prepMinutes: prep,
cookMinutes: cook,
createdAt: testTime,
updatedAt: testTime,
);
final quickPasta = make('quick pasta', course: 'main', prep: 10, cook: 15);
final roast = make('roast', course: 'main', prep: 30, cook: 90);
final cake = make('cake', course: 'dessert', prep: 20, cook: 40);
final noTime = make('no time', course: 'main');
final all = [quickPasta, roast, cake, noTime];
List<String> names(DecisionChoice c) => c.matching(all).map((r) => r.name).toList();
test('no answers match everything', () {
expect(names(const DecisionChoice()), ['quick pasta', 'roast', 'cake', 'no time']);
});
test('several Courses can be chosen', () {
expect(names(const DecisionChoice(courseIds: {'dessert'})), ['cake']);
expect(names(const DecisionChoice(courseIds: {'dessert', 'main'})), hasLength(4));
});
test('a time limit uses the total time and leaves out Recipes without one', () {
expect(names(const DecisionChoice(time: TimeLimit.upTo30)), ['quick pasta']);
expect(names(const DecisionChoice(time: TimeLimit.upTo60)), ['quick pasta', 'cake']);
expect(names(const DecisionChoice(time: TimeLimit.any)), contains('no time'));
});
test('answers combine, and can be loosened one at a time', () {
const choice = DecisionChoice(courseIds: {'main'}, time: TimeLimit.upTo60);
expect(names(choice), ['quick pasta']);
expect(names(choice.withoutTimeLimit()), ['quick pasta', 'roast', 'no time']);
expect(names(choice.withAllCourses()), ['quick pasta', 'cake']);
});
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:cookit/src/recipes/ingredients_text.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import '../support/fixtures.dart';
void main() {
setUpAll(() => initializeDateFormatting('de'));
String label(String key) => {'gram': 'g', 'tablespoon': 'EL'}[key]!;
test('one Ingredient Line per line, groups separated by an empty line', () {
expect(
ingredientsAsText(recipe().ingredientGroups, label),
'300 g Mehl\n'
'2–3 EL Olivenöl (gutes)\n'
'\n'
'Rosmarin',
);
});
test('scaled amounts and empty groups', () {
final groups = [
const IngredientGroup(lines: []),
const IngredientGroup(lines: [IngredientLine(amount: 1, name: 'Ei')]),
];
expect(
ingredientsAsText([
for (final g in groups) IngredientGroup(lines: [for (final l in g.lines) l.scaled(1.5)]),
], label),
'1½ Ei',
);
});
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/recipes/recipe_draft.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
setUpAll(() => initializeDateFormatting('de'));
group('ingredients', () {
const groups = [
IngredientGroup(
lines: [
IngredientLine(amount: 300, unitKey: 'gram', name: 'Mehl'),
IngredientLine(amount: 2, amountMax: 3, unitKey: 'tablespoon', name: 'Öl', remark: 'gutes'),
],
),
IngredientGroup(lines: [IngredientLine(name: 'Salz')]),
IngredientGroup(
name: 'Topping',
lines: [IngredientLine(amount: 0.5, unitKey: 'pinch', name: 'Rosmarin')],
),
];
test('the first unnamed group has no heading; later groups get one', () {
final entries = ingredientEntriesFrom(groups);
expect(entries.map((e) => e.runtimeType), [LineEntry, LineEntry, GroupEntry, LineEntry, GroupEntry, LineEntry]);
expect((entries[1] as LineEntry).amount.text, '2–3');
expect((entries[5] as LineEntry).amount.text, '½');
});
test('round-trips', () {
final back = ingredientGroupsFrom(ingredientEntriesFrom(groups));
expect([for (final g in back) g.toJson()], [for (final g in groups) g.toJson()]);
});
test('lines without a name and empty groups are dropped', () {
final entries = [
GroupEntry('Leer'),
LineEntry()..name.text = ' ',
GroupEntry(' Sauce '),
LineEntry()
..amount.text = '1,5'
..name.text = 'Sahne',
];
final groups = ingredientGroupsFrom(entries);
expect(groups, hasLength(1));
expect(groups.single.name, 'Sauce');
expect(groups.single.lines.single.amount, 1.5);
});
test('a heading with an empty name becomes an unnamed group', () {
final entries = [LineEntry()..name.text = 'Mehl', GroupEntry(''), LineEntry()..name.text = 'Zucker'];
expect(ingredientGroupsFrom(entries).map((g) => g.name), [null, null]);
});
});
group('steps', () {
test('round-trips sections', () {
const sections = [
StepSection(steps: ['Ofen vorheizen.']),
StepSection(title: 'Teig', steps: ['Kneten.', 'Ruhen lassen.']),
];
final back = stepSectionsFrom(stepEntriesFrom(sections));
expect([for (final s in back) s.toJson()], [for (final s in sections) s.toJson()]);
});
test('empty steps are dropped', () {
final entries = [StepTextEntry(' '), SectionEntry('Soße'), StepTextEntry('Rühren.')];
final sections = stepSectionsFrom(entries);
expect(sections.single.title, 'Soße');
expect(sections.single.steps, ['Rühren.']);
});
});
test('tagsFrom trims and removes duplicates ignoring case', () {
expect(tagsFrom('vegan, schnell , Vegan,,'), ['vegan', 'schnell']);
});
test('linesFrom', () {
expect(linesFrom(' https://a.de \n\nhttps://b.de'), ['https://a.de', 'https://b.de']);
});
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/recipes/recipe_filter.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
void main() {
final kaesespaetzle = recipe(
id: 'a',
name: 'Käsespätzle',
courseId: 'main',
tags: ['schwäbisch'],
ingredientGroups: const [
IngredientGroup(
lines: [
IngredientLine(name: 'Bergkäse'),
IngredientLine(name: 'Zwiebeln'),
],
),
],
favourite: true,
);
final tiramisu = recipe(id: 'b', name: 'Tiramisu', courseId: 'dessert', reviewReasons: ['Portionen fehlen.']);
List<String> names(RecipeFilter f) => [kaesespaetzle, tiramisu].where(f.matches).map((r) => r.name).toList();
test('empty filter matches everything', () {
expect(names(const RecipeFilter()), ['Käsespätzle', 'Tiramisu']);
});
test('search ignores case and umlauts and looks at name, tags and ingredients', () {
expect(names(const RecipeFilter(query: 'kase')), ['Käsespätzle']);
expect(names(const RecipeFilter(query: 'SCHWABISCH')), ['Käsespätzle']);
expect(names(const RecipeFilter(query: 'zwiebel')), ['Käsespätzle']);
expect(names(const RecipeFilter(query: 'tira')), ['Tiramisu']);
});
test('every search word must match', () {
expect(names(const RecipeFilter(query: 'käse zwiebel')), ['Käsespätzle']);
expect(names(const RecipeFilter(query: 'käse tiramisu')), isEmpty);
});
test('course, favourites and review filters combine', () {
expect(names(const RecipeFilter(courseId: 'dessert')), ['Tiramisu']);
expect(names(const RecipeFilter(favouritesOnly: true)), ['Käsespätzle']);
expect(names(const RecipeFilter(reviewOnly: true)), ['Tiramisu']);
expect(names(const RecipeFilter(reviewOnly: true, favouritesOnly: true)), isEmpty);
});
test('normalizeForSearch', () {
expect(normalizeForSearch(' Größe Crêpes '), 'grosse crepes');
});
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('unit keys round-trip through JSON, built-in or own', () {
for (final key in ['gram', 'bunch', 'own-1234']) {
final line = IngredientLine.fromJson(IngredientLine(amount: 1, unitKey: key, name: 'x').toJson());
expect(line.unitKey, key);
}
});
test('scaled multiplies amount and range, keeps the rest', () {
const line = IngredientLine(amount: 2, amountMax: 3, unitKey: 'tablespoon', name: 'Öl', remark: 'gutes');
final half = line.scaled(0.5);
expect((half.amount, half.amountMax, half.unitKey, half.name, half.remark), (1, 1.5, 'tablespoon', 'Öl', 'gutes'));
expect(const IngredientLine(name: 'Salz').scaled(2).amount, isNull);
});
}
+104
View File
@@ -0,0 +1,104 @@
import 'dart:typed_data';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/recipes/course_repository.dart';
import 'package:cookit/src/recipes/models/course.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 RecipeRepository recipes;
late CourseRepository courses;
setUp(() async {
db = testDatabase();
photos = await tempPhotoStore();
recipes = RecipeRepository(db, photos);
courses = CourseRepository(db);
});
tearDown(() async {
await db.close();
await photos.directory.delete(recursive: true);
});
test('a fresh database starts with the default Courses in order', () async {
final all = await courses.allCourses();
expect(all.map((c) => c.name), defaultCourses.map((c) => c.$1));
expect(all.first.label, '🍝 Hauptgericht');
});
test('saves and reads back a whole Recipe', () async {
final course = (await courses.allCourses()).first;
final original = recipe(courseId: course.id, tags: ['Brot'], reviewReasons: ['Portionen fehlen.']);
await recipes.saveRecipe(original);
final read = (await recipes.watchRecipe('r1').first)!;
expect(read.name, 'Focaccia');
expect(read.courseId, course.id);
expect(read.tags, ['Brot']);
expect(read.calories!.kcal, 775);
expect(
[for (final g in read.ingredientGroups) g.toJson()],
[for (final g in original.ingredientGroups) g.toJson()],
);
expect(read.stepSections.single.title, 'Teig');
expect(read.sources, ['https://example.org/focaccia']);
expect(read.needsReview, isTrue);
});
test('watchRecipes is sorted by name ignoring case', () async {
await recipes.saveRecipe(recipe(id: 'b', name: 'zwiebelkuchen'));
await recipes.saveRecipe(recipe(id: 'a', name: 'Apfelstrudel'));
await recipes.saveRecipe(recipe(id: 'c', name: 'Brot'));
final names = (await recipes.watchRecipes().first).map((r) => r.name);
expect(names, ['Apfelstrudel', 'Brot', 'zwiebelkuchen']);
});
test('favourite and clearing the Review Flag', () async {
await recipes.saveRecipe(recipe(reviewReasons: ['x']));
await recipes.setFavourite('r1', true);
await recipes.clearReview('r1');
final read = (await recipes.watchRecipe('r1').first)!;
expect(read.favourite, isTrue);
expect(read.needsReview, isFalse);
});
test('deleting a Recipe removes its Photo files', () async {
final photo = await photos.saveBytes(Uint8List.fromList([1, 2, 3]));
await recipes.saveRecipe(recipe(photos: [photo]));
await recipes.deleteRecipe((await recipes.watchRecipe('r1').first)!);
expect(await recipes.watchRecipe('r1').first, isNull);
expect(photos.file(photo).existsSync(), isFalse);
});
test('deleting a Course keeps its Recipes without one', () async {
final course = await courses.addCourse('Suppe', emoji: '🍲');
await recipes.saveRecipe(recipe(courseId: course.id));
expect(await courses.recipeCount(course.id), 1);
await courses.deleteCourse(course.id);
expect((await recipes.watchRecipe('r1').first)!.courseId, isNull);
});
test('findOrCreate matches names ignoring case', () async {
final existing = await courses.findOrCreate('dessert');
expect(existing.name, 'Dessert');
final created = await courses.findOrCreate('Suppe');
expect(created.position, defaultCourses.length);
});
test('reorder and update Courses', () async {
final all = await courses.allCourses();
await courses.reorder(all.reversed.toList());
await courses.updateCourse(all.first.id, name: 'Hauptspeise', emoji: null);
final reordered = await courses.allCourses();
expect(reordered.last.name, 'Hauptspeise');
expect(reordered.last.emoji, isNull);
});
}
+115
View File
@@ -0,0 +1,115 @@
import 'dart:math';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/localization/app_localizations_de.dart';
import 'package:cookit/src/recipes/course_repository.dart';
import 'package:cookit/src/recipes/photo_store.dart';
import 'package:cookit/src/recipes/recipe_list_provider.dart';
import 'package:cookit/src/recipes/recipe_repository.dart';
import 'package:cookit/src/recipes/time_format.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_database.dart';
void main() {
group('Idee für heute', () {
late AppDatabase db;
late PhotoStore photos;
late RecipeRepository recipes;
var now = DateTime(2026, 10, 20, 18);
setUp(() async {
db = testDatabase();
photos = await tempPhotoStore();
recipes = RecipeRepository(db, photos);
for (final (i, name) in ['Avocado Pasta', 'Brot', 'Curry', 'Dürüm'].indexed) {
await recipes.saveRecipe(recipe(id: 'r$i', name: name));
}
});
tearDown(() async {
await db.close();
await photos.directory.delete(recursive: true);
});
Future<RecipeListProvider> provider() async {
final p = RecipeListProvider(recipes, CourseRepository(db), clock: () => now, random: Random(1));
await Future<void>.delayed(const Duration(milliseconds: 20));
return p;
}
test('is stable within a day and drawn at random from day to day', () async {
final p = await provider();
final today = p.suggestion;
now = DateTime(2026, 10, 20, 23, 59);
expect(p.suggestion!.id, today!.id);
// Not the alphabetical walk it used to be: over two weeks the picks jump around.
final picks = <String>[];
for (var d = 0; d < 14; d++) {
now = DateTime(2026, 11, 1 + d, 12);
picks.add(p.suggestion!.id);
}
expect(picks.toSet().length, greaterThan(2));
final steps = [
for (var i = 1; i < picks.length; i++)
(int.parse(picks[i].substring(1)) - int.parse(picks[i - 1].substring(1))) % 4,
];
expect(steps.every((s) => s == 1), isFalse);
p.dispose();
});
test('"Andere Idee" holds until midnight only', () async {
now = DateTime(2026, 10, 20, 18);
final p = await provider();
p.shuffleSuggestion();
final shuffled = p.suggestion!.id;
now = DateTime(2026, 10, 20, 23, 0);
expect(p.suggestion!.id, shuffled);
now = DateTime(2026, 10, 21, 8);
final p2 = await provider();
expect(p.suggestion!.id, p2.suggestion!.id);
p.dispose();
p2.dispose();
});
test('"Andere Idee" always picks a different Recipe', () async {
final p = await provider();
for (var i = 0; i < 10; i++) {
final before = p.suggestion!.id;
p.shuffleSuggestion();
expect(p.suggestion!.id, isNot(before));
}
p.dispose();
});
});
group('time format', () {
final l10n = AppLocalizationsDe();
test('formatMinutes', () {
expect(formatMinutes(l10n, 45), '45 Min.');
expect(formatMinutes(l10n, 60), '1 Std.');
expect(formatMinutes(l10n, 70), '1 Std. 10 Min.');
});
test('greeting follows the time of day', () {
final cases = {
(2, 0): 'Schönen Abend',
(5, 0): 'Guten Morgen',
(11, 29): 'Guten Morgen',
(11, 30): 'Mahlzeit!',
(13, 59): 'Mahlzeit!',
(14, 0): 'Guten Tag',
(17, 0): 'Guten Abend',
(21, 59): 'Guten Abend',
(22, 0): 'Schönen Abend',
};
cases.forEach((time, expected) {
final (hour, minute) = time;
expect(greeting(l10n, DateTime(2026, 1, 1, hour, minute)), expected, reason: '$hour:$minute');
});
});
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:cookit/src/recipes/text_breaks.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
String shown(String text) => withNaturalBreaks(text).replaceAll('​', '|');
test('breaks after hyphens, ampersands and slashes, and between words written together', () {
expect(shown('ApfelWeißkohlPfanne&Schupfi'), 'Apfel|Weißkohl|Pfanne&|Schupfi');
expect(shown('Brokkoli-Sahne-Nudeln'), 'Brokkoli-|Sahne-|Nudeln');
expect(shown('AvocadoMinze Smoothie'), 'Avocado|Minze Smoothie');
expect(shown('Salz/Pfeffer'), 'Salz/|Pfeffer');
expect(shown('Öl-Ärger'), 'Öl-|Ärger');
});
test('leaves ordinary names and abbreviations alone', () {
expect(shown('Tomatensoße mit Spätzle'), 'Tomatensoße mit Spätzle');
expect(shown('BBQ Burger'), 'BBQ Burger');
expect(shown('Gef. Hähnchenfilet'), 'Gef. Hähnchenfilet');
expect(shown('Rösti - knusprig'), 'Rösti - knusprig');
expect(withNaturalBreaks('ApfelWeißkohl').replaceAll('​', ''), 'ApfelWeißkohl');
});
test('a line now ends at a natural break, not mid-word', () {
// Test font: every glyph is fontSize wide. Room for 15 glyphs.
List<double> lineWidths(String text) {
final p = TextPainter(
text: TextSpan(text: text, style: const TextStyle(fontSize: 10)),
textDirection: TextDirection.ltr,
)..layout(maxWidth: 155);
return [for (final l in p.computeLineMetrics()) l.width];
}
expect(lineWidths('ApfelWeißkohlPfanne'), [150, 40]); // "ApfelWeißkohlPf" | "anne"
expect(lineWidths(withNaturalBreaks('ApfelWeißkohlPfanne')), [130, 60]); // "ApfelWeißkohl" | "Pfanne"
});
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:io';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/recipes/models/unit.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';
void main() {
late AppDatabase db;
late PhotoStore photos;
late UnitRepository units;
late RecipeRepository recipes;
setUp(() async {
db = testDatabase();
photos = await tempPhotoStore();
units = UnitRepository(db);
recipes = RecipeRepository(db, photos);
});
tearDown(() async {
await db.close();
await photos.directory.delete(recursive: true);
});
test('a fresh database lists every built-in unit, the common ones offered directly', () async {
final all = await units.allUnits();
expect(all.map((u) => u.key), Unit.values.map((u) => u.key));
expect(all.where((u) => u.direct).map((u) => u.key), [
for (final u in Unit.values)
if (u.common) u.key,
]);
expect(all.every((u) => u.builtIn), isTrue);
});
test('own units can be added, renamed, offered directly or not, and sorted', () async {
final cube = await units.addOwn('Würfel');
expect(cube.key, startsWith('own-'));
await units.rename(cube.key, 'Brühwürfel');
await units.setDirect(cube.key, false);
await units.setDirect('gram', false);
final all = await units.allUnits();
final reordered = [all.last, ...all.take(all.length - 1)];
await units.reorder(reordered);
final after = await units.allUnits();
expect(after.first.key, cube.key);
expect((after.first.label, after.first.direct, after.first.builtIn), ('Brühwürfel', false, false));
expect(after.firstWhere((u) => u.key == 'gram').direct, isFalse);
});
test('built-in units keep their name', () async {
await units.rename('gram', 'Gramm!');
expect((await units.allUnits()).firstWhere((u) => u.key == 'gram').label, isNull);
});
test('deleting an own unit moves its name into the Ingredient Lines that used it', () async {
final cube = await units.addOwn('Würfel');
await recipes.saveRecipe(
recipe(
ingredientGroups: [
IngredientGroup(
lines: [
IngredientLine(amount: 2, unitKey: cube.key, name: 'Brühe'),
const IngredientLine(amount: 100, unitKey: 'gram', name: 'Mehl'),
],
),
],
),
);
await recipes.saveRecipe(recipe(id: 'r2', name: 'Ohne Würfel'));
expect(await units.recipeCount(cube.key), 1);
await units.deleteOwn(cube);
final lines = (await recipes.getRecipe('r1'))!.ingredientGroups.single.lines;
expect((lines[0].amount, lines[0].unitKey, lines[0].name), (2, null, 'Würfel Brühe'));
expect((lines[1].unitKey, lines[1].name), ('gram', 'Mehl'));
expect((await units.allUnits()).any((u) => u.key == cube.key), isFalse);
});
test('an existing version 1 database gets the units table 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());
// Make it look like the database before units existed.
await fileDb.customStatement('DROP TABLE units');
await fileDb.customStatement('PRAGMA user_version = 1');
await fileDb.close();
fileDb = testDatabase(file: file);
expect(await UnitRepository(fileDb).allUnits(), hasLength(Unit.values.length));
expect((await RecipeRepository(fileDb, photos).getRecipe('r1'))!.name, 'Focaccia');
await fileDb.close();
await dir.delete(recursive: true);
db = testDatabase(); // for tearDown
});
}
+93
View File
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:cookit/src/bundle/bundle_codec.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/sharing/recipe_code.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
void main() {
final now = DateTime(2026, 10, 23);
Matcher fails(RecipeCodeError error) => throwsA(isA<RecipeCodeException>().having((e) => e.error, 'error', error));
group('Base45 (RFC 9285 examples)', () {
const examples = {'AB': 'BB8', 'Hello!!': '%69 VD92EX0', 'base-45': 'UJCLQE7W581', 'ietf!': 'QED8WEX0'};
examples.forEach((plain, encoded) {
test(plain, () {
expect(base45Encode(utf8.encode(plain)), encoded);
expect(utf8.decode(base45Decode(encoded)), plain);
});
});
test('rejects text that is not Base45', () {
expect(() => base45Decode('GGW'), throwsFormatException); // 65536
expect(() => base45Decode('ab'), throwsFormatException);
});
});
test('a Recipe survives the round trip, without what is personal', () {
final text = RecipeCode.encode(
recipe(favourite: true, reviewReasons: ['Portionen fehlen.'], photos: ['a.jpg']),
courseName: 'Brot',
units: const [BundleUnit(key: 'own-1', label: 'Würfel', direct: true)],
);
expect(text, startsWith('COOKIT1:'));
expect(RegExp(r'^[0-9A-Z $%*+\-./:]+$').hasMatch(text), isTrue, reason: 'QR alphanumeric mode only');
final shared = RecipeCode.decode(text, now: now);
final r = shared.recipe;
expect(shared.courseName, 'Brot');
expect(shared.units.single.label, 'Würfel');
expect(r.name, 'Focaccia');
expect(r.servings, 4);
expect(r.prepMinutes, 30);
expect(r.calories?.kcal, 775);
expect(r.ingredientGroups[0].lines[1].amountMax, 3);
expect(r.ingredientGroups[0].lines[1].remark, 'gutes');
expect(r.ingredientGroups[1].name, 'Topping');
expect(r.stepSections.single.steps, ['Kneten.', 'Gehen lassen.']);
expect(r.sources, ['https://example.org/focaccia']);
expect(r.notes, '22x22 cm Form');
expect(r.favourite, isFalse);
expect(r.reviewReasons, isEmpty);
expect(r.photos, isEmpty);
expect(r.createdAt, now);
});
test('notes stay home when asked to', () {
final text = RecipeCode.encode(recipe(), includeNotes: false);
expect(RecipeCode.decode(text, now: now).recipe.notes, isNull);
});
test('other codes, newer versions and damaged codes are told apart', () {
final good = RecipeCode.encode(recipe());
expect(() => RecipeCode.decode('https://example.org', now: now), fails(RecipeCodeError.notARecipeCode));
expect(() => RecipeCode.decode('COOKITX:123', now: now), fails(RecipeCodeError.notARecipeCode));
expect(
() => RecipeCode.decode(good.replaceFirst('COOKIT1', 'COOKIT2'), now: now),
fails(RecipeCodeError.newerVersion),
);
expect(() => RecipeCode.decode(good.substring(0, good.length - 7), now: now), fails(RecipeCodeError.damaged));
});
test('the QR code is as small as the text allows, and too much is refused', () {
final small = RecipeCode.qrCode(RecipeCode.encode(recipe()));
expect(small.typeNumber, lessThan(20));
// A long Recipe still fits one code…
final steps = [for (var i = 0; i < 40; i++) 'Schritt $i: ${'Rühren und abschmecken, dann weiter. ' * 2}'];
final long = recipe(stepSections: [StepSection(steps: steps)]);
expect(RecipeCode.qrCode(RecipeCode.encode(long)).typeNumber, greaterThan(small.typeNumber));
// …but text no compression can shrink does not.
final noise = [for (var i = 0; i < 3000; i++) String.fromCharCode(0x4E00 + (i * 7919) % 20000)].join();
final huge = recipe(
stepSections: [
StepSection(steps: [noise]),
],
);
expect(() => RecipeCode.qrCode(RecipeCode.encode(huge)), fails(RecipeCodeError.tooLong));
});
}
@@ -0,0 +1,97 @@
import 'dart:typed_data';
import 'package:cookit/src/bundle/bundle_service.dart';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/recipes/course_repository.dart';
import 'package:cookit/src/recipes/models/recipe.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:cookit/src/sharing/recipe_share_service.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_database.dart';
/// One cook's collection.
class Phone {
Phone._(this.db, this.photos)
: recipes = RecipeRepository(db, photos),
courses = CourseRepository(db),
units = UnitRepository(db) {
bundles = BundleService(recipes, courses, units, photos);
shares = RecipeShareService(recipes, courses, units, photos, 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;
late final BundleService bundles;
late final RecipeShareService shares;
}
void main() {
test('a Recipe Code arrives as a new Recipe with its Course and own unit', () async {
final mine = await Phone.create();
final theirs = await Phone.create();
final course = await mine.courses.addCourse('Brotzeit');
final cube = await mine.units.addOwn('Würfel');
final sent = recipe(courseId: course.id, favourite: true).copyWith(
ingredientGroups: [
IngredientGroup(
lines: [IngredientLine(amount: 1, unitKey: cube.key, name: 'Hefe')],
),
],
);
await mine.recipes.saveRecipe(sent);
final saved = await theirs.shares.save(theirs.shares.readCode(await mine.shares.recipeCode(sent)));
expect(saved.id, isNot(sent.id));
expect(saved.favourite, isFalse);
expect((await theirs.courses.allCourses()).firstWhere((c) => c.id == saved.courseId).name, 'Brotzeit');
expect((await theirs.units.allUnits()).firstWhere((u) => u.key == cube.key).label, 'Würfel');
expect((await theirs.recipes.allRecipes()).single.ingredientLines.single.unitKey, cube.key);
});
test('a shared file carries the Photos, and saving twice gives two Recipes', () async {
final mine = await Phone.create();
final theirs = await Phone.create();
final photo = await mine.photos.saveBytes(Uint8List.fromList([1, 2, 3]));
final sent = recipe(photos: [photo], reviewReasons: ['Prüfen']);
await mine.recipes.saveRecipe(sent);
final file = await mine.shares.recipeFile(sent, includeNotes: false);
final shared = (await theirs.shares.readFile(file))!;
expect(shared.photos.single, [1, 2, 3]);
expect(shared.recipe.notes, isNull);
expect(shared.recipe.reviewReasons, isEmpty);
expect(await theirs.shares.hasRecipeNamed('focaccia'), isFalse);
await theirs.shares.save(shared);
expect(await theirs.shares.hasRecipeNamed('focaccia'), isTrue);
await theirs.shares.save(shared);
final all = await theirs.recipes.allRecipes();
expect(all, hasLength(2));
expect(all.map((r) => r.id).toSet(), hasLength(2));
expect(all.first.photos, hasLength(1));
});
test('a file with a whole collection is not a single shared Recipe', () async {
final mine = await Phone.create();
await mine.recipes.saveRecipe(recipe());
await mine.recipes.saveRecipe(recipe(id: 'r2', name: 'Pizza'));
expect(await mine.shares.readFile(await mine.bundles.exportBundle()), isNull);
});
}
+55
View File
@@ -0,0 +1,55 @@
import 'dart:io';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/recipes/photo_store.dart';
final testTime = DateTime.utc(2026, 10, 20, 12);
Recipe recipe({
String id = 'r1',
String name = 'Focaccia',
String? courseId,
List<String> tags = const [],
List<IngredientGroup>? ingredientGroups,
List<StepSection>? stepSections,
List<String> photos = const [],
bool favourite = false,
List<String> reviewReasons = const [],
}) => Recipe(
id: id,
name: name,
courseId: courseId,
tags: tags,
servings: 4,
prepMinutes: 30,
cookMinutes: 20,
calories: const Calories(775, CaloriesBasis.perServing),
ingredientGroups:
ingredientGroups ??
const [
IngredientGroup(
lines: [
IngredientLine(amount: 300, unitKey: 'gram', name: 'Mehl'),
IngredientLine(amount: 2, amountMax: 3, unitKey: 'tablespoon', name: 'Olivenöl', remark: 'gutes'),
],
),
IngredientGroup(
name: 'Topping',
lines: [IngredientLine(name: 'Rosmarin')],
),
],
stepSections:
stepSections ??
const [
StepSection(title: 'Teig', steps: ['Kneten.', 'Gehen lassen.']),
],
photos: photos,
sources: const ['https://example.org/focaccia'],
notes: '22x22 cm Form',
favourite: favourite,
reviewReasons: reviewReasons,
createdAt: testTime,
updatedAt: testTime,
);
Future<PhotoStore> tempPhotoStore() async => PhotoStore(await Directory.systemTemp.createTemp('cookit_photos_'));
+87
View File
@@ -0,0 +1,87 @@
import 'dart:io';
import 'package:cookit/src/app.dart';
import 'package:cookit/src/bundle/bundle_service.dart';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/planning/plan_repository.dart';
import 'package:cookit/src/planning/plan_share_service.dart';
import 'package:cookit/src/planning/week_plan_provider.dart';
import 'package:cookit/src/recipes/course_repository.dart';
import 'package:cookit/src/recipes/photo_store.dart';
import 'package:cookit/src/recipes/recipe_list_provider.dart';
import 'package:cookit/src/recipes/recipe_repository.dart';
import 'package:cookit/src/recipes/unit_catalog.dart';
import 'package:cookit/src/recipes/unit_repository.dart';
import 'package:cookit/src/settings/settings_controller.dart';
import 'package:cookit/src/sharing/recipe_share_service.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fixtures.dart';
import 'test_database.dart';
/// The real app wired to an in-memory database and a temporary photo folder.
class TestApp {
TestApp._(this.db, this.photos)
: recipes = RecipeRepository(db, photos),
courses = CourseRepository(db),
units = UnitRepository(db),
plan = PlanRepository(db),
bundles = BundleService(RecipeRepository(db, photos), CourseRepository(db), UnitRepository(db), photos);
final AppDatabase db;
final PhotoStore photos;
final RecipeRepository recipes;
final CourseRepository courses;
final UnitRepository units;
final PlanRepository plan;
final BundleService bundles;
static Future<TestApp> create(WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
// A tall phone (400 x 2000 logical px) so long forms are built without scrolling.
tester.view.physicalSize = const Size(1200, 6000);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
final photos = (await tester.runAsync(tempPhotoStore))!;
return TestApp._(testDatabase(), photos);
}
Widget build() => MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => SettingsController(SettingsService())),
Provider.value(value: photos),
Provider.value(value: recipes),
Provider.value(value: courses),
Provider.value(value: units),
Provider.value(value: plan),
ChangeNotifierProvider(create: (_) => UnitCatalog(units)),
Provider.value(value: bundles),
Provider(create: (_) => RecipeShareService(recipes, courses, units, photos, bundles)),
Provider(create: (_) => PlanShareService(recipes, courses, units, photos, plan, bundles)),
ChangeNotifierProvider(create: (_) => RecipeListProvider(recipes, courses)),
ChangeNotifierProvider(create: (_) => WeekPlanProvider(plan)),
],
child: const CookItApp(),
);
/// Lets database work and the resulting stream updates finish, then redraws.
Future<void> settle(WidgetTester tester) async {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
}
Future<void> dispose(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.runAsync(() async {
await db.close();
try {
await photos.directory.delete(recursive: true);
} on FileSystemException {
// Windows keeps a just-decoded image file open for a moment; the temp folder can stay.
}
});
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'dart:ffi';
import 'dart:io';
import 'package:cookit/src/database/database.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:sqlite3/open.dart';
/// An in-memory database for tests, or one in [file] to test migrations. On
/// Windows hosts the sqlite3 library comes from COOKIT_SQLITE_DLL if set,
/// otherwise from the system's winsqlite3.dll.
AppDatabase testDatabase({File? file}) {
if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () {
final custom = Platform.environment['COOKIT_SQLITE_DLL'];
return DynamicLibrary.open(custom ?? 'winsqlite3.dll');
});
}
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
final executor = file == null ? NativeDatabase.memory() : NativeDatabase(file);
// Synchronous stream teardown keeps widget tests from hanging on pending timers.
return AppDatabase(DatabaseConnection(executor, closeStreamsSynchronously: true));
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/recipes/views/widgets/recipe_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_app.dart';
/// A Recipe name as shown on a grid card (the suggestion card may show it too).
Finder inGrid(String text) => find.descendant(of: find.byType(RecipeCard), matching: find.text(text));
void main() {
testWidgets('home grid shows Recipes and filters them by search, Course and review', (tester) async {
final app = await TestApp.create(tester);
final dessert = (await tester.runAsync(() => app.courses.findOrCreate('Dessert')))!;
await tester.runAsync(() async {
await app.recipes.saveRecipe(recipe(id: 'a', name: 'Käsespätzle', tags: ['schwäbisch']));
await app.recipes.saveRecipe(
recipe(id: 'b', name: 'Tiramisu', courseId: dessert.id, reviewReasons: ['Portionen fehlen.']),
);
});
await tester.pumpWidget(app.build());
await app.settle(tester);
expect(inGrid('Käsespätzle'), findsOneWidget);
expect(inGrid('Tiramisu'), findsOneWidget);
expect(find.text('IDEE FÜR HEUTE'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'kase');
await tester.pumpAndSettle();
expect(inGrid('Käsespätzle'), findsOneWidget);
expect(inGrid('Tiramisu'), findsNothing);
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.text('1 Rezept wartet auf deinen Blick'));
await tester.pumpAndSettle();
expect(inGrid('Käsespätzle'), findsNothing);
expect(inGrid('Tiramisu'), findsOneWidget);
await tester.tap(find.text('Nur Rezepte zum Prüfen – alle zeigen'));
await tester.pumpAndSettle();
final chips = find.byWidgetPredicate((w) => w is ListView && w.scrollDirection == Axis.horizontal);
final dessertChip = find.descendant(of: chips, matching: find.text('Dessert'));
await tester.scrollUntilVisible(
dessertChip,
200,
scrollable: find.descendant(of: chips, matching: find.byType(Scrollable)),
);
await tester.tap(dessertChip);
await tester.pumpAndSettle();
expect(inGrid('Tiramisu'), findsOneWidget);
expect(inGrid('Käsespätzle'), findsNothing);
await app.dispose(tester);
});
testWidgets('Cooking Mode walks through the ingredients and every step', (tester) async {
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe()));
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(inGrid('Focaccia'));
await app.settle(tester);
await tester.tap(find.text('Kochmodus starten'));
await app.settle(tester);
expect(find.text('Bereit zum Kochen?'), findsOneWidget);
expect(find.text('300 g'), findsOneWidget);
await tester.tap(find.byTooltip('Mehr Portionen'));
await tester.pumpAndSettle();
expect(find.text('5'), findsOneWidget);
expect(find.text('375 g'), findsOneWidget);
await tester.tap(find.text('Los geht’s'));
await tester.pumpAndSettle();
expect(find.text('Schritt 1 von 2'), findsOneWidget);
expect(find.text('Kneten.'), findsOneWidget);
expect(find.text('Teig'), findsOneWidget);
await tester.tap(find.text('Weiter'));
await tester.pumpAndSettle();
expect(find.text('Gehen lassen.'), findsOneWidget);
await tester.tap(find.text('Fertig'));
await tester.pumpAndSettle();
expect(find.text('Guten Appetit!'), findsOneWidget);
await tester.tap(find.text('Zurück zum Rezept'));
await app.settle(tester);
expect(find.text('Kochmodus starten'), findsOneWidget);
await app.dispose(tester);
});
testWidgets('the heart on a card toggles the favourite', (tester) async {
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe()));
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.byTooltip('Favorit')));
await app.settle(tester);
expect((await tester.runAsync(app.recipes.allRecipes))!.single.favourite, isTrue);
await app.dispose(tester);
});
testWidgets('empty collection explains how to start', (tester) async {
final app = await TestApp.create(tester);
await tester.pumpWidget(app.build());
await app.settle(tester);
expect(find.text('Noch keine Rezepte'), findsOneWidget);
await app.dispose(tester);
});
testWidgets('detail shows the Recipe, and "Erledigt" clears its Review Flag', (tester) async {
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe(reviewReasons: ['Portionen fehlen.'])));
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(inGrid('Focaccia'));
await app.settle(tester);
expect(find.text('Bitte prüfen'), findsOneWidget);
expect(find.text('• Portionen fehlen.'), findsOneWidget);
expect(find.text('300 g'), findsOneWidget);
expect(find.text('2–3 EL'), findsOneWidget);
expect(find.text('Topping'), findsOneWidget);
expect(find.text('775 kcal'), findsOneWidget);
expect(find.text('pro Portion'), findsOneWidget);
expect(find.text('50 Min.'), findsOneWidget);
expect(find.text('22x22 cm Form'), findsOneWidget, reason: 'Notes show under the Zutaten tab too');
await tester.tap(find.text('Zubereitung · 2'));
await tester.pumpAndSettle();
expect(find.text('Teig'), findsOneWidget);
expect(find.text('Kneten.'), findsOneWidget);
expect(find.text('22x22 cm Form'), findsOneWidget);
await tester.tap(find.text('Erledigt'));
await app.settle(tester);
expect(find.text('Bitte prüfen'), findsNothing);
await app.dispose(tester);
});
testWidgets('a new Recipe can be created with ingredients and steps', (tester) async {
final app = await TestApp.create(tester);
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.byTooltip('Neues Rezept'));
await app.settle(tester);
await tester.tap(find.text('Speichern'));
await tester.pumpAndSettle();
expect(find.text('Bitte gib dem Rezept einen Namen.'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextFormField, 'Name'), 'Pfannkuchen');
await tester.enterText(find.widgetWithText(TextFormField, 'Portionen'), '2');
final ingredientName = find.widgetWithText(TextFormField, 'Zutat');
await tester.enterText(find.widgetWithText(TextFormField, 'Menge'), '1,5');
await tester.enterText(ingredientName, 'Mehl');
await tester.tap(find.text('–'));
await tester.pumpAndSettle();
expect(find.text('Bund'), findsNothing);
await tester.tap(find.text('Weitere Einheiten …'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ChoiceChip, 'Bund'));
await tester.pumpAndSettle();
expect(find.text('Bund'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextFormField, 'Schritt 1'), 'Alles verrühren.');
await tester.tap(find.text('Speichern'));
await app.settle(tester);
expect(inGrid('Pfannkuchen'), findsOneWidget);
final saved = (await tester.runAsync(app.recipes.allRecipes))!.single;
expect(saved.servings, 2);
expect(saved.ingredientGroups.single.lines.single.amount, 1.5);
expect(saved.ingredientGroups.single.lines.single.name, 'Mehl');
expect(saved.ingredientGroups.single.lines.single.unitKey, 'bunch');
expect(saved.stepSections.single.steps, ['Alles verrühren.']);
expect(saved.calories, isNull);
expect(saved.needsReview, isFalse);
await app.dispose(tester);
});
testWidgets('an invalid amount blocks saving', (tester) async {
final app = await TestApp.create(tester);
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.byTooltip('Neues Rezept'));
await app.settle(tester);
await tester.enterText(find.widgetWithText(TextFormField, 'Name'), 'Test');
await tester.enterText(find.widgetWithText(TextFormField, 'Menge'), 'viel');
await tester.tap(find.text('Speichern'));
await app.settle(tester);
expect(find.text('z. B. 2, 1,5, ½ oder 2-3'), findsOneWidget);
expect(await tester.runAsync(app.recipes.allRecipes), isEmpty);
await app.dispose(tester);
});
testWidgets('editing keeps favourite and Review Flag', (tester) async {
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe(favourite: true, reviewReasons: ['x'])));
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(inGrid('Focaccia'));
await app.settle(tester);
await tester.tap(find.byTooltip('Bearbeiten'));
await app.settle(tester);
await tester.enterText(find.widgetWithText(TextFormField, 'Name'), 'Focaccia mit Rosmarin');
await tester.tap(find.text('Speichern'));
await app.settle(tester);
final saved = (await tester.runAsync(app.recipes.allRecipes))!.single;
expect(saved.name, 'Focaccia mit Rosmarin');
expect(saved.favourite, isTrue);
expect(saved.reviewReasons, ['x']);
expect(saved.calories!.basis, CaloriesBasis.perServing);
expect(saved.ingredientGroups.map((g) => g.name), [null, 'Topping']);
expect(saved.stepSections.single.title, 'Teig');
await app.dispose(tester);
});
}
+183
View File
@@ -0,0 +1,183 @@
import 'package:cookit/src/recipes/views/widgets/recipe_card.dart';
import 'package:cookit/src/recipes/views/widgets/recipe_photo.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_app.dart';
Finder inSheet(Finder finder) => find.descendant(of: find.byType(BottomSheet), matching: finder);
void main() {
testWidgets('"Hilf mir entscheiden" narrows down by Course and time', (tester) async {
final app = await TestApp.create(tester);
final courses = (await tester.runAsync(app.courses.allCourses))!;
final dessert = courses.firstWhere((c) => c.name == 'Dessert');
final main = courses.firstWhere((c) => c.name == 'Hauptgericht');
await tester.runAsync(() async {
await app.recipes.saveRecipe(
recipe(
id: 'a',
name: 'Kaiserschmarrn',
courseId: dessert.id,
).copyWith(prepMinutes: () => 10, cookMinutes: () => 10),
);
await app.recipes.saveRecipe(recipe(id: 'b', name: 'Tiramisu', courseId: dessert.id)); // 50 min
await app.recipes.saveRecipe(recipe(id: 'c', name: 'Rinderrouladen', courseId: main.id));
});
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.byTooltip('Hilf mir entscheiden'));
await tester.pumpAndSettle();
expect(find.text('Worauf hast du Lust?'), findsOneWidget);
await tester.tap(inSheet(find.text('Dessert')));
await tester.pumpAndSettle();
await tester.tap(find.text('Weiter'));
await tester.pumpAndSettle();
expect(find.text('Wie viel Zeit hast du?'), findsOneWidget);
await tester.tap(find.text('bis 30 Min.'));
await tester.pumpAndSettle();
await tester.tap(find.text('Vorschlag zeigen'));
await tester.pumpAndSettle();
expect(find.text('1 Rezept passt'), findsOneWidget);
expect(inSheet(find.text('Kaiserschmarrn')), findsOneWidget);
await tester.tap(find.text('Rezept öffnen'));
await app.settle(tester);
expect(find.text('Kochmodus starten'), findsOneWidget);
expect(find.text('Kaiserschmarrn'), findsOneWidget);
await app.dispose(tester);
});
testWidgets('the decision helper offers a looser choice when nothing fits', (tester) async {
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe())); // 50 min, no Course
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.byTooltip('Hilf mir entscheiden'));
await tester.pumpAndSettle();
await tester.tap(find.text('Weiter'));
await tester.pumpAndSettle();
await tester.tap(find.text('bis 30 Min.'));
await tester.tap(find.text('Vorschlag zeigen'));
await tester.pumpAndSettle();
expect(find.text('Dazu passt leider nichts.'), findsOneWidget);
await tester.tap(find.text('Ohne Zeitlimit: 1'));
await tester.pumpAndSettle();
expect(find.text('1 Rezept passt'), findsOneWidget);
await app.dispose(tester);
});
testWidgets('Darstellung switches between light and dark', (tester) async {
final app = await TestApp.create(tester);
await tester.pumpWidget(app.build());
await app.settle(tester);
ThemeMode mode() => tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode!;
expect(mode(), ThemeMode.light);
await tester.tap(find.byTooltip('Einstellungen'));
await tester.pumpAndSettle();
await tester.tap(find.text('Dunkel'));
await app.settle(tester);
expect(mode(), ThemeMode.dark);
await app.dispose(tester);
});
testWidgets('an own unit added in the settings is offered in the unit picker', (tester) async {
final app = await TestApp.create(tester);
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.byTooltip('Einstellungen'));
await tester.pumpAndSettle();
await tester.tap(find.text('Maßeinheiten bearbeiten'));
await app.settle(tester);
await tester.tap(find.text('Einheit hinzufügen'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField), 'Würfel');
await tester.tap(find.text('Speichern'));
await app.settle(tester);
expect(find.text('Würfel'), findsOneWidget);
expect(find.text('Eigene Einheit'), findsOneWidget);
await tester.tap(find.byTooltip('Zurück'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Zurück'));
await app.settle(tester);
await tester.tap(find.byTooltip('Neues Rezept'));
await app.settle(tester);
await tester.tap(find.text('–'));
await tester.pumpAndSettle();
expect(find.widgetWithText(ChoiceChip, 'Würfel'), findsOneWidget);
await app.dispose(tester);
});
testWidgets('tapping a photo can make it the main Photo', (tester) async {
final app = await TestApp.create(tester);
final (first, second) = (await tester.runAsync(() async {
final a = await app.photos.saveBytes(Uint8List.fromList([1]));
final b = await app.photos.saveBytes(Uint8List.fromList([2]));
await app.recipes.saveRecipe(recipe(photos: [a, b]));
return (a, b);
}))!;
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.text('Focaccia')));
await app.settle(tester);
await tester.tap(find.byTooltip('Bearbeiten'));
await app.settle(tester);
expect(find.textContaining('das erste Foto ist das Hauptfoto'), findsOneWidget);
// The photo is covered by its own tap layer, which is what receives the tap.
await tester.tap(find.byType(RecipePhoto).at(1), warnIfMissed: false);
await tester.pumpAndSettle();
await tester.tap(find.text('Als Hauptfoto festlegen'));
await tester.pumpAndSettle();
await tester.tap(find.text('Speichern'));
await app.settle(tester);
expect((await tester.runAsync(() => app.recipes.getRecipe('r1')))!.photos, [second, first]);
await app.dispose(tester);
});
testWidgets('"Zutaten teilen" shares the ingredients for the chosen servings', (tester) async {
final shared = <Map<Object?, Object?>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/share'),
(call) async {
shared.add(call.arguments as Map<Object?, Object?>);
return 'dev.fluttercommunity.plus/share/unavailable';
},
);
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe()));
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.text('Focaccia')));
await app.settle(tester);
await tester.scrollUntilVisible(find.text('Zutaten teilen'), 200, scrollable: find.byType(Scrollable).first);
await tester.tap(find.text('Zutaten teilen'));
await tester.pumpAndSettle();
await tester.tap(inSheet(find.byTooltip('Mehr Portionen')));
await tester.pumpAndSettle();
await tester.tap(find.text('Teilen'));
await tester.pumpAndSettle();
expect(shared, hasLength(1));
expect(shared.single['text'], '375 g Mehl\n2½–3¾ EL Olivenöl (gutes)\n\nRosmarin');
expect(shared.single['subject'], 'Focaccia – Zutaten für 5 Portionen');
await app.dispose(tester);
});
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:cookit/src/planning/day.dart';
import 'package:cookit/src/planning/plan_share_service.dart';
import 'package:cookit/src/planning/views/shared_week_view.dart';
import 'package:cookit/src/recipes/views/widgets/recipe_card.dart';
import 'package:cookit/src/sharing/recipe_code.dart';
import 'package:cookit/src/sharing/views/shared_recipe_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../support/fixtures.dart';
import '../support/test_app.dart';
void main() {
testWidgets('"Rezept teilen" shows the Recipe Code and can leave the notes out', (tester) async {
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe()));
await tester.pumpWidget(app.build());
await app.settle(tester);
expect(find.byTooltip('Rezept scannen'), findsOneWidget);
await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.text('Focaccia')));
await app.settle(tester);
await tester.tap(find.byTooltip('Rezept teilen'));
await app.settle(tester);
expect(find.byType(QrImageView), findsOneWidget);
expect(find.text('Als Datei teilen'), findsOneWidget);
await tester.tap(find.text('Notizen mitschicken'));
await app.settle(tester);
expect(tester.widget<SwitchListTile>(find.byType(SwitchListTile)).value, isFalse);
expect(find.byType(QrImageView), findsOneWidget);
await app.dispose(tester);
});
testWidgets('a received Recipe is previewed, flagged as a double and saved as a copy', (tester) async {
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe()));
final shared = RecipeCode.decode(RecipeCode.encode(recipe(), courseName: 'Brot'), now: DateTime(2026));
await tester.pumpWidget(app.build());
await app.settle(tester);
tester.state<NavigatorState>(find.byType(Navigator)).pushNamed(SharedRecipeView.routeName, arguments: shared);
await app.settle(tester);
expect(find.text('Rezept empfangen'), findsOneWidget);
expect(find.text('Brot · 4 Portionen · 50 Min.'), findsOneWidget);
expect(find.text('Mehl'), findsOneWidget);
expect(find.text('1. Kneten.'), findsOneWidget);
expect(find.textContaining('Du hast schon ein Rezept „Focaccia“'), findsOneWidget);
await tester.tap(find.text('Speichern'));
await app.settle(tester);
expect(find.text('„Focaccia“ gespeichert'), findsOneWidget);
expect(find.text('Kochmodus starten'), findsOneWidget);
final all = (await tester.runAsync(app.recipes.allRecipes))!;
expect(all, hasLength(2));
final courses = (await tester.runAsync(app.courses.allCourses))!;
expect(courses.map((c) => c.name), contains('Brot'));
await app.dispose(tester);
});
testWidgets('a received week is previewed and adopted next to what is planned', (tester) async {
final app = await TestApp.create(tester);
final monday = DateTime.utc(2026, 10, 19);
final week = (await tester.runAsync(() async {
await app.recipes.saveRecipe(recipe());
final meals = await app.plan.allMeals();
final abend = meals.firstWhere((m) => m.name == 'Abend');
await app.plan.addEntry(monday, abend.id, recipeId: 'r1');
await app.plan.addEntry(addDays(monday, 2), abend.id, note: 'Essen gehen');
final shares = PlanShareService(app.recipes, app.courses, app.units, app.photos, app.plan, app.bundles);
return SharedWeek(await app.bundles.read(await shares.weekFile(monday)));
}))!;
await tester.pumpWidget(app.build());
await app.settle(tester);
tester.state<NavigatorState>(find.byType(Navigator)).pushNamed(SharedWeekView.routeName, arguments: week);
await app.settle(tester);
expect(find.text('Wochenplan empfangen'), findsOneWidget);
expect(find.text('2 Einträge · keine neuen Rezepte'), findsOneWidget);
expect(find.textContaining('schon 2 Einträge'), findsOneWidget);
expect(find.text('Focaccia'), findsOneWidget);
expect(find.text('Essen gehen'), findsOneWidget);
await tester.tap(find.text('Übernehmen'));
await app.settle(tester);
expect(find.text('Wochenplan 19.–25. Okt. übernommen'), findsOneWidget);
expect(await tester.runAsync(app.plan.allEntries), hasLength(4));
expect(await tester.runAsync(app.recipes.allRecipes), hasLength(1));
await app.dispose(tester);
});
}
+352
View File
@@ -0,0 +1,352 @@
import 'package:cookit/src/planning/day.dart';
import 'package:cookit/src/planning/models/meal.dart';
import 'package:cookit/src/recipes/views/widgets/recipe_card.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_app.dart';
final today = dayOf(DateTime.now());
/// The grid cell of [meal] on [day].
Finder cell(DateTime day, Meal meal) => find.byKey(ValueKey('${dayKey(day)}-${meal.id}'));
Finder inCell(DateTime day, Meal meal, Finder finder) => find.descendant(of: cell(day, meal), matching: finder);
Finder inSheet(Finder finder) => find.descendant(of: find.byType(BottomSheet), matching: finder);
Future<void> openWeekPlan(WidgetTester tester, TestApp app) async {
await tester.tap(find.byTooltip('Wochenplan'));
await app.settle(tester);
}
Future<(Meal, Meal)> meals(WidgetTester tester, TestApp app) async {
final all = (await tester.runAsync(app.plan.allMeals))!;
return (all.firstWhere((m) => m.name == 'Mittag'), all.firstWhere((m) => m.name == 'Abend'));
}
/// Long-presses [from] and drops it on [to].
Future<void> drag(WidgetTester tester, TestApp app, Finder from, Finder to) async {
final start = tester.getCenter(from);
final gesture = await tester.startGesture(start);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 100));
// While lifted, the entry's place shows an empty outline, so [from] finds nothing.
await gesture.moveTo(start + const Offset(0, 20));
await tester.pump();
await gesture.moveTo(tester.getCenter(to));
await tester.pump();
await gesture.up();
await app.settle(tester);
}
void main() {
testWidgets('"Einplanen" puts a Recipe on today\'s Abend, shown in the grid and the banners', (tester) async {
final app = await TestApp.create(tester);
await tester.runAsync(() => app.recipes.saveRecipe(recipe()));
final (_, abend) = await meals(tester, app);
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.text('Focaccia')));
await app.settle(tester);
await tester.tap(find.byTooltip('Einplanen'));
await tester.pumpAndSettle();
await tester.tap(inSheet(find.text('Einplanen')));
await app.settle(tester);
expect(find.text('Eingeplant: Heute · Abend'), findsOneWidget);
await tester.tap(find.byTooltip('Zurück'));
await app.settle(tester);
expect(find.text('HEUTE GEPLANT'), findsOneWidget);
expect(find.text('Abend · 50 Min.'), findsOneWidget);
await tester.tap(find.byTooltip('Lieber eine Idee'));
await tester.pumpAndSettle();
expect(find.text('IDEE FÜR HEUTE'), findsOneWidget);
await openWeekPlan(tester, app);
expect(find.text('Diese Woche'), findsOneWidget);
expect(inCell(today, abend, find.text('Focaccia')), findsOneWidget);
expect(find.textContaining('HEUTE ·'), findsOneWidget);
expect(find.text('Kochen'), findsOneWidget);
await app.dispose(tester);
});
testWidgets('a note is added with "+", removed from its cell and brought back', (tester) async {
final app = await TestApp.create(tester);
final (mittag, _) = await meals(tester, app);
await tester.pumpWidget(app.build());
await app.settle(tester);
await openWeekPlan(tester, app);
await tester.tap(cell(today, mittag));
await tester.pumpAndSettle();
await tester.enterText(inSheet(find.byType(TextField)), 'Essen gehen');
await tester.pumpAndSettle();
await tester.tap(find.text('Als Notiz: „Essen gehen“'));
await app.settle(tester);
expect(inCell(today, mittag, find.text('Essen gehen')), findsOneWidget);
await tester.tap(cell(today, mittag));
await tester.pumpAndSettle();
await tester.tap(inSheet(find.text('Entfernen')));
await app.settle(tester);
expect(inCell(today, mittag, find.text('Essen gehen')), findsNothing);
expect(find.text('„Essen gehen“ entfernt'), findsOneWidget);
await tester.tap(find.text('Rückgängig'));
await app.settle(tester);
expect(inCell(today, mittag, find.text('Essen gehen')), findsOneWidget);
await app.dispose(tester);
});
testWidgets('dragging an entry onto another cell adds it there, and each entry drags on its own', (tester) async {
final app = await TestApp.create(tester);
final (mittag, abend) = await meals(tester, app);
await tester.runAsync(() async {
await app.recipes.saveRecipe(recipe());
await app.plan.addEntry(today, abend.id, recipeId: 'r1');
await app.plan.addEntry(today, mittag.id, note: 'Salat');
});
await tester.pumpWidget(app.build());
await app.settle(tester);
await openWeekPlan(tester, app);
await drag(tester, app, cell(today, abend), cell(today, mittag));
final entries = (await tester.runAsync(app.plan.allEntries))!;
expect(entries.map((e) => (e.mealId, e.recipeId ?? e.note)), [(mittag.id, 'Salat'), (mittag.id, 'r1')]);
// Both show in the cell, one slim line each.
expect(inCell(today, mittag, find.text('Salat')), findsOneWidget);
expect(inCell(today, mittag, find.text('Focaccia')), findsOneWidget);
// The navigation bar is back once the finger is up.
expect(find.text('Hier ablegen zum Entfernen'), findsNothing);
// The second one can be dragged on its own.
await drag(tester, app, inCell(today, mittag, find.text('Focaccia')), cell(today, abend));
final after = (await tester.runAsync(app.plan.allEntries))!;
expect(
after.map((e) => (e.mealId, e.recipeId ?? e.note)),
unorderedEquals([(mittag.id, 'Salat'), (abend.id, 'r1')]),
);
await app.dispose(tester);
});
testWidgets('dropping an entry on "Nächste Woche" moves it seven days on', (tester) async {
final app = await TestApp.create(tester);
final (_, abend) = await meals(tester, app);
await tester.runAsync(() async {
await app.recipes.saveRecipe(recipe());
await app.plan.addEntry(today, abend.id, recipeId: 'r1');
});
await tester.pumpWidget(app.build());
await app.settle(tester);
await openWeekPlan(tester, app);
final gesture = await tester.startGesture(tester.getCenter(cell(today, abend)));
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 100));
await gesture.moveBy(const Offset(0, 20));
await tester.pump();
expect(find.text('Hier ablegen zum Entfernen'), findsOneWidget);
await gesture.moveTo(tester.getCenter(find.text('Nächste Woche')));
await tester.pump();
await gesture.up();
await app.settle(tester);
expect((await tester.runAsync(app.plan.allEntries))!.single.day, addDays(today, 7));
expect(find.text('Nächste Woche'), findsOneWidget); // the grid followed it
expect(inCell(addDays(today, 7), abend, find.text('Focaccia')), findsOneWidget);
await app.dispose(tester);
});
testWidgets('dropping an entry on the remove zone removes it, with undo', (tester) async {
final app = await TestApp.create(tester);
final (_, abend) = await meals(tester, app);
await tester.runAsync(() async {
await app.recipes.saveRecipe(recipe());
await app.plan.addEntry(today, abend.id, recipeId: 'r1');
});
await tester.pumpWidget(app.build());
await app.settle(tester);
await openWeekPlan(tester, app);
final gesture = await tester.startGesture(tester.getCenter(cell(today, abend)));
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 100));
await gesture.moveBy(const Offset(0, 20));
await tester.pump();
await gesture.moveTo(tester.getCenter(find.text('Hier ablegen zum Entfernen')));
await tester.pump();
await gesture.up();
await app.settle(tester);
expect(await tester.runAsync(app.plan.allEntries), isEmpty);
await tester.tap(find.text('Rückgängig'));
await app.settle(tester);
expect((await tester.runAsync(app.plan.allEntries))!.single.recipeId, 'r1');
await app.dispose(tester);
});
testWidgets('"Woche füllen" gives every Abend from today on a different Hauptgericht', (tester) async {
final app = await TestApp.create(tester);
final courses = (await tester.runAsync(app.courses.allCourses))!;
final main = courses.firstWhere((c) => c.name == 'Hauptgericht');
final dessert = courses.firstWhere((c) => c.name == 'Dessert');
await tester.runAsync(() async {
for (var i = 0; i < 7; i++) {
await app.recipes.saveRecipe(recipe(id: 'm$i', name: 'Gericht $i', courseId: main.id));
}
await app.recipes.saveRecipe(recipe(id: 'd', name: 'Pudding', courseId: dessert.id));
});
await tester.pumpWidget(app.build());
await app.settle(tester);
await openWeekPlan(tester, app);
await tester.tap(find.byTooltip('Woche füllen'));
await tester.pumpAndSettle();
await tester.tap(find.text('Füllen'));
await app.settle(tester);
final daysLeft = addDays(weekStartOf(today, DateTime.monday), 6).difference(today).inDays + 1;
final entries = (await tester.runAsync(app.plan.allEntries))!;
final (_, abend) = await meals(tester, app);
expect(entries, hasLength(daysLeft));
expect(entries.map((e) => e.recipeId).toSet(), hasLength(daysLeft));
expect(entries.every((e) => e.mealId == abend.id && e.recipeId!.startsWith('m')), isTrue);
expect(find.text(daysLeft == 1 ? '1 Tag gefüllt.' : '$daysLeft Tage gefüllt.'), findsOneWidget);
// Cells show the Course's icon.
expect(inCell(today, abend, find.text('🍝')), findsOneWidget);
await app.dispose(tester);
});
testWidgets('"Zutaten der Woche teilen" adds up what is planned', (tester) async {
final shared = <Map<Object?, Object?>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/share'),
(call) async {
shared.add(call.arguments as Map<Object?, Object?>);
return 'dev.fluttercommunity.plus/share/unavailable';
},
);
final app = await TestApp.create(tester);
await tester.runAsync(() async {
await app.recipes.saveRecipe(recipe());
for (final meal in await app.plan.allMeals()) {
await app.plan.addEntry(today, meal.id, recipeId: 'r1');
}
});
await tester.pumpWidget(app.build());
await app.settle(tester);
await openWeekPlan(tester, app);
await tester.tap(find.byTooltip('Teilen'));
await tester.pumpAndSettle();
await tester.tap(find.text('Zutaten der Woche teilen'));
await tester.pumpAndSettle();
await tester.tap(inSheet(find.text('Teilen')));
await tester.pumpAndSettle();
expect(shared.single['text'], '600 g Mehl\n4–6 EL Olivenöl (gutes)\nRosmarin');
await app.dispose(tester);
});
testWidgets('a Meal added in the settings becomes a column of the grid', (tester) async {
final app = await TestApp.create(tester);
await tester.pumpWidget(app.build());
await app.settle(tester);
await tester.tap(find.byTooltip('Einstellungen'));
await tester.pumpAndSettle();
await tester.tap(find.text('Mahlzeiten bearbeiten'));
await app.settle(tester);
await tester.tap(find.text('Mahlzeit hinzufügen'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField), 'Frühstück');
await tester.tap(find.text('Speichern'));
await app.settle(tester);
await tester.tap(find.byTooltip('Zurück'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Zurück'));
await app.settle(tester);
await openWeekPlan(tester, app);
expect(find.text('Frühstück'), findsOneWidget);
final fruehstueck = (await tester.runAsync(app.plan.allMeals))!.last;
expect(cell(today, fruehstueck), findsOneWidget);
await app.dispose(tester);
});
testWidgets('the add sheet filters Recipes by Course', (tester) async {
final app = await TestApp.create(tester);
final courses = (await tester.runAsync(app.courses.allCourses))!;
final dessert = courses.firstWhere((c) => c.name == 'Dessert');
final main = courses.firstWhere((c) => c.name == 'Hauptgericht');
final (mittag, _) = await meals(tester, app);
await tester.runAsync(() async {
await app.recipes.saveRecipe(recipe(id: 'a', name: 'Tiramisu', courseId: dessert.id));
await app.recipes.saveRecipe(recipe(id: 'b', name: 'Rinderrouladen', courseId: main.id));
});
await tester.pumpWidget(app.build());
await app.settle(tester);
await openWeekPlan(tester, app);
await tester.tap(cell(today, mittag));
await tester.pumpAndSettle();
expect(inSheet(find.text('Tiramisu')), findsOneWidget);
expect(inSheet(find.text('Rinderrouladen')), findsOneWidget);
// The Course chips scroll sideways; Dessert is far to the right.
await tester.dragUntilVisible(
inSheet(find.text(dessert.label)),
inSheet(find.byType(ListView)).first,
const Offset(-200, 0),
);
await tester.ensureVisible(inSheet(find.text(dessert.label)));
await tester.pumpAndSettle();
await tester.tap(inSheet(find.text(dessert.label)));
await tester.pumpAndSettle();
expect(inSheet(find.text('Tiramisu')), findsOneWidget);
expect(inSheet(find.text('Rinderrouladen')), findsNothing);
await tester.tap(inSheet(find.text('Tiramisu')));
await app.settle(tester);
expect(inCell(today, mittag, find.text('Tiramisu')), findsOneWidget);
await app.dispose(tester);
});
testWidgets('a note can be edited from its cell', (tester) async {
final app = await TestApp.create(tester);
final (mittag, _) = await meals(tester, app);
await tester.runAsync(() => app.plan.addEntry(today, mittag.id, note: 'Essen gehen'));
await tester.pumpWidget(app.build());
await app.settle(tester);
await openWeekPlan(tester, app);
await tester.tap(cell(today, mittag));
await tester.pumpAndSettle();
await tester.tap(inSheet(find.text('Bearbeiten')));
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)),
'Pizza bestellen',
);
await tester.tap(find.text('Speichern'));
await app.settle(tester);
expect(inCell(today, mittag, find.text('Pizza bestellen')), findsOneWidget);
expect((await tester.runAsync(app.plan.allEntries))!.single.note, 'Pizza bestellen');
await app.dispose(tester);
});
}