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,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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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_'));
|
||||
@@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user