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,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
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user