Initial commit: CookIT recipe collection and week planner

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

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
import 'package:cookit/src/recipes/amount.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
setUpAll(() => initializeDateFormatting('de'));
group('parseAmount', () {
final cases = <String, Amount?>{
'': (amount: null, amountMax: null),
'2': (amount: 2, amountMax: null),
'1,5': (amount: 1.5, amountMax: null),
'0.25': (amount: 0.25, amountMax: null),
'½': (amount: 0.5, amountMax: null),
'1½': (amount: 1.5, amountMax: null),
'1/2': (amount: 0.5, amountMax: null),
'1 1/2': (amount: 1.5, amountMax: null),
'2-3': (amount: 2, amountMax: 3),
'10 – 12': (amount: 10, amountMax: 12),
'1 bis 2': (amount: 1, amountMax: 2),
'etwas': null,
'2-': null,
};
cases.forEach((input, expected) {
test('"$input"', () => expect(parseAmount(input), expected));
});
});
group('formatAmount', () {
test('uses fraction glyphs and German decimals', () {
expect(formatAmount(1.5, null), '1½');
expect(formatAmount(0.25, null), '¼');
expect(formatAmount(0.333, null), '⅓');
expect(formatAmount(2, null), '2');
expect(formatAmount(1.4, null), '1,4');
expect(formatAmount(1000, null), '1.000');
expect(formatAmount(2, 3), '2–3');
expect(formatAmount(null, null), '');
});
test('round-trips through parseAmount', () {
for (final value in [0.5, 1.5, 2.0, 1.4, 250.0]) {
expect(parseAmount(formatAmount(value, null))!.amount, closeTo(value, 0.001));
}
});
});
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:cookit/src/recipes/decision.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
void main() {
Recipe make(String id, {String? course, int? prep, int? cook}) => Recipe(
id: id,
name: id,
courseId: course,
prepMinutes: prep,
cookMinutes: cook,
createdAt: testTime,
updatedAt: testTime,
);
final quickPasta = make('quick pasta', course: 'main', prep: 10, cook: 15);
final roast = make('roast', course: 'main', prep: 30, cook: 90);
final cake = make('cake', course: 'dessert', prep: 20, cook: 40);
final noTime = make('no time', course: 'main');
final all = [quickPasta, roast, cake, noTime];
List<String> names(DecisionChoice c) => c.matching(all).map((r) => r.name).toList();
test('no answers match everything', () {
expect(names(const DecisionChoice()), ['quick pasta', 'roast', 'cake', 'no time']);
});
test('several Courses can be chosen', () {
expect(names(const DecisionChoice(courseIds: {'dessert'})), ['cake']);
expect(names(const DecisionChoice(courseIds: {'dessert', 'main'})), hasLength(4));
});
test('a time limit uses the total time and leaves out Recipes without one', () {
expect(names(const DecisionChoice(time: TimeLimit.upTo30)), ['quick pasta']);
expect(names(const DecisionChoice(time: TimeLimit.upTo60)), ['quick pasta', 'cake']);
expect(names(const DecisionChoice(time: TimeLimit.any)), contains('no time'));
});
test('answers combine, and can be loosened one at a time', () {
const choice = DecisionChoice(courseIds: {'main'}, time: TimeLimit.upTo60);
expect(names(choice), ['quick pasta']);
expect(names(choice.withoutTimeLimit()), ['quick pasta', 'roast', 'no time']);
expect(names(choice.withAllCourses()), ['quick pasta', 'cake']);
});
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:cookit/src/recipes/ingredients_text.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import '../support/fixtures.dart';
void main() {
setUpAll(() => initializeDateFormatting('de'));
String label(String key) => {'gram': 'g', 'tablespoon': 'EL'}[key]!;
test('one Ingredient Line per line, groups separated by an empty line', () {
expect(
ingredientsAsText(recipe().ingredientGroups, label),
'300 g Mehl\n'
'2–3 EL Olivenöl (gutes)\n'
'\n'
'Rosmarin',
);
});
test('scaled amounts and empty groups', () {
final groups = [
const IngredientGroup(lines: []),
const IngredientGroup(lines: [IngredientLine(amount: 1, name: 'Ei')]),
];
expect(
ingredientsAsText([
for (final g in groups) IngredientGroup(lines: [for (final l in g.lines) l.scaled(1.5)]),
], label),
'1½ Ei',
);
});
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/recipes/recipe_draft.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
setUpAll(() => initializeDateFormatting('de'));
group('ingredients', () {
const groups = [
IngredientGroup(
lines: [
IngredientLine(amount: 300, unitKey: 'gram', name: 'Mehl'),
IngredientLine(amount: 2, amountMax: 3, unitKey: 'tablespoon', name: 'Öl', remark: 'gutes'),
],
),
IngredientGroup(lines: [IngredientLine(name: 'Salz')]),
IngredientGroup(
name: 'Topping',
lines: [IngredientLine(amount: 0.5, unitKey: 'pinch', name: 'Rosmarin')],
),
];
test('the first unnamed group has no heading; later groups get one', () {
final entries = ingredientEntriesFrom(groups);
expect(entries.map((e) => e.runtimeType), [LineEntry, LineEntry, GroupEntry, LineEntry, GroupEntry, LineEntry]);
expect((entries[1] as LineEntry).amount.text, '2–3');
expect((entries[5] as LineEntry).amount.text, '½');
});
test('round-trips', () {
final back = ingredientGroupsFrom(ingredientEntriesFrom(groups));
expect([for (final g in back) g.toJson()], [for (final g in groups) g.toJson()]);
});
test('lines without a name and empty groups are dropped', () {
final entries = [
GroupEntry('Leer'),
LineEntry()..name.text = ' ',
GroupEntry(' Sauce '),
LineEntry()
..amount.text = '1,5'
..name.text = 'Sahne',
];
final groups = ingredientGroupsFrom(entries);
expect(groups, hasLength(1));
expect(groups.single.name, 'Sauce');
expect(groups.single.lines.single.amount, 1.5);
});
test('a heading with an empty name becomes an unnamed group', () {
final entries = [LineEntry()..name.text = 'Mehl', GroupEntry(''), LineEntry()..name.text = 'Zucker'];
expect(ingredientGroupsFrom(entries).map((g) => g.name), [null, null]);
});
});
group('steps', () {
test('round-trips sections', () {
const sections = [
StepSection(steps: ['Ofen vorheizen.']),
StepSection(title: 'Teig', steps: ['Kneten.', 'Ruhen lassen.']),
];
final back = stepSectionsFrom(stepEntriesFrom(sections));
expect([for (final s in back) s.toJson()], [for (final s in sections) s.toJson()]);
});
test('empty steps are dropped', () {
final entries = [StepTextEntry(' '), SectionEntry('Soße'), StepTextEntry('Rühren.')];
final sections = stepSectionsFrom(entries);
expect(sections.single.title, 'Soße');
expect(sections.single.steps, ['Rühren.']);
});
});
test('tagsFrom trims and removes duplicates ignoring case', () {
expect(tagsFrom('vegan, schnell , Vegan,,'), ['vegan', 'schnell']);
});
test('linesFrom', () {
expect(linesFrom(' https://a.de \n\nhttps://b.de'), ['https://a.de', 'https://b.de']);
});
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/recipes/recipe_filter.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
void main() {
final kaesespaetzle = recipe(
id: 'a',
name: 'Käsespätzle',
courseId: 'main',
tags: ['schwäbisch'],
ingredientGroups: const [
IngredientGroup(
lines: [
IngredientLine(name: 'Bergkäse'),
IngredientLine(name: 'Zwiebeln'),
],
),
],
favourite: true,
);
final tiramisu = recipe(id: 'b', name: 'Tiramisu', courseId: 'dessert', reviewReasons: ['Portionen fehlen.']);
List<String> names(RecipeFilter f) => [kaesespaetzle, tiramisu].where(f.matches).map((r) => r.name).toList();
test('empty filter matches everything', () {
expect(names(const RecipeFilter()), ['Käsespätzle', 'Tiramisu']);
});
test('search ignores case and umlauts and looks at name, tags and ingredients', () {
expect(names(const RecipeFilter(query: 'kase')), ['Käsespätzle']);
expect(names(const RecipeFilter(query: 'SCHWABISCH')), ['Käsespätzle']);
expect(names(const RecipeFilter(query: 'zwiebel')), ['Käsespätzle']);
expect(names(const RecipeFilter(query: 'tira')), ['Tiramisu']);
});
test('every search word must match', () {
expect(names(const RecipeFilter(query: 'käse zwiebel')), ['Käsespätzle']);
expect(names(const RecipeFilter(query: 'käse tiramisu')), isEmpty);
});
test('course, favourites and review filters combine', () {
expect(names(const RecipeFilter(courseId: 'dessert')), ['Tiramisu']);
expect(names(const RecipeFilter(favouritesOnly: true)), ['Käsespätzle']);
expect(names(const RecipeFilter(reviewOnly: true)), ['Tiramisu']);
expect(names(const RecipeFilter(reviewOnly: true, favouritesOnly: true)), isEmpty);
});
test('normalizeForSearch', () {
expect(normalizeForSearch(' Größe Crêpes '), 'grosse crepes');
});
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('unit keys round-trip through JSON, built-in or own', () {
for (final key in ['gram', 'bunch', 'own-1234']) {
final line = IngredientLine.fromJson(IngredientLine(amount: 1, unitKey: key, name: 'x').toJson());
expect(line.unitKey, key);
}
});
test('scaled multiplies amount and range, keeps the rest', () {
const line = IngredientLine(amount: 2, amountMax: 3, unitKey: 'tablespoon', name: 'Öl', remark: 'gutes');
final half = line.scaled(0.5);
expect((half.amount, half.amountMax, half.unitKey, half.name, half.remark), (1, 1.5, 'tablespoon', 'Öl', 'gutes'));
expect(const IngredientLine(name: 'Salz').scaled(2).amount, isNull);
});
}
+104
View File
@@ -0,0 +1,104 @@
import 'dart:typed_data';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/recipes/course_repository.dart';
import 'package:cookit/src/recipes/models/course.dart';
import 'package:cookit/src/recipes/photo_store.dart';
import 'package:cookit/src/recipes/recipe_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_database.dart';
void main() {
late AppDatabase db;
late PhotoStore photos;
late RecipeRepository recipes;
late CourseRepository courses;
setUp(() async {
db = testDatabase();
photos = await tempPhotoStore();
recipes = RecipeRepository(db, photos);
courses = CourseRepository(db);
});
tearDown(() async {
await db.close();
await photos.directory.delete(recursive: true);
});
test('a fresh database starts with the default Courses in order', () async {
final all = await courses.allCourses();
expect(all.map((c) => c.name), defaultCourses.map((c) => c.$1));
expect(all.first.label, '🍝 Hauptgericht');
});
test('saves and reads back a whole Recipe', () async {
final course = (await courses.allCourses()).first;
final original = recipe(courseId: course.id, tags: ['Brot'], reviewReasons: ['Portionen fehlen.']);
await recipes.saveRecipe(original);
final read = (await recipes.watchRecipe('r1').first)!;
expect(read.name, 'Focaccia');
expect(read.courseId, course.id);
expect(read.tags, ['Brot']);
expect(read.calories!.kcal, 775);
expect(
[for (final g in read.ingredientGroups) g.toJson()],
[for (final g in original.ingredientGroups) g.toJson()],
);
expect(read.stepSections.single.title, 'Teig');
expect(read.sources, ['https://example.org/focaccia']);
expect(read.needsReview, isTrue);
});
test('watchRecipes is sorted by name ignoring case', () async {
await recipes.saveRecipe(recipe(id: 'b', name: 'zwiebelkuchen'));
await recipes.saveRecipe(recipe(id: 'a', name: 'Apfelstrudel'));
await recipes.saveRecipe(recipe(id: 'c', name: 'Brot'));
final names = (await recipes.watchRecipes().first).map((r) => r.name);
expect(names, ['Apfelstrudel', 'Brot', 'zwiebelkuchen']);
});
test('favourite and clearing the Review Flag', () async {
await recipes.saveRecipe(recipe(reviewReasons: ['x']));
await recipes.setFavourite('r1', true);
await recipes.clearReview('r1');
final read = (await recipes.watchRecipe('r1').first)!;
expect(read.favourite, isTrue);
expect(read.needsReview, isFalse);
});
test('deleting a Recipe removes its Photo files', () async {
final photo = await photos.saveBytes(Uint8List.fromList([1, 2, 3]));
await recipes.saveRecipe(recipe(photos: [photo]));
await recipes.deleteRecipe((await recipes.watchRecipe('r1').first)!);
expect(await recipes.watchRecipe('r1').first, isNull);
expect(photos.file(photo).existsSync(), isFalse);
});
test('deleting a Course keeps its Recipes without one', () async {
final course = await courses.addCourse('Suppe', emoji: '🍲');
await recipes.saveRecipe(recipe(courseId: course.id));
expect(await courses.recipeCount(course.id), 1);
await courses.deleteCourse(course.id);
expect((await recipes.watchRecipe('r1').first)!.courseId, isNull);
});
test('findOrCreate matches names ignoring case', () async {
final existing = await courses.findOrCreate('dessert');
expect(existing.name, 'Dessert');
final created = await courses.findOrCreate('Suppe');
expect(created.position, defaultCourses.length);
});
test('reorder and update Courses', () async {
final all = await courses.allCourses();
await courses.reorder(all.reversed.toList());
await courses.updateCourse(all.first.id, name: 'Hauptspeise', emoji: null);
final reordered = await courses.allCourses();
expect(reordered.last.name, 'Hauptspeise');
expect(reordered.last.emoji, isNull);
});
}
+115
View File
@@ -0,0 +1,115 @@
import 'dart:math';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/localization/app_localizations_de.dart';
import 'package:cookit/src/recipes/course_repository.dart';
import 'package:cookit/src/recipes/photo_store.dart';
import 'package:cookit/src/recipes/recipe_list_provider.dart';
import 'package:cookit/src/recipes/recipe_repository.dart';
import 'package:cookit/src/recipes/time_format.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_database.dart';
void main() {
group('Idee für heute', () {
late AppDatabase db;
late PhotoStore photos;
late RecipeRepository recipes;
var now = DateTime(2026, 10, 20, 18);
setUp(() async {
db = testDatabase();
photos = await tempPhotoStore();
recipes = RecipeRepository(db, photos);
for (final (i, name) in ['Avocado Pasta', 'Brot', 'Curry', 'Dürüm'].indexed) {
await recipes.saveRecipe(recipe(id: 'r$i', name: name));
}
});
tearDown(() async {
await db.close();
await photos.directory.delete(recursive: true);
});
Future<RecipeListProvider> provider() async {
final p = RecipeListProvider(recipes, CourseRepository(db), clock: () => now, random: Random(1));
await Future<void>.delayed(const Duration(milliseconds: 20));
return p;
}
test('is stable within a day and drawn at random from day to day', () async {
final p = await provider();
final today = p.suggestion;
now = DateTime(2026, 10, 20, 23, 59);
expect(p.suggestion!.id, today!.id);
// Not the alphabetical walk it used to be: over two weeks the picks jump around.
final picks = <String>[];
for (var d = 0; d < 14; d++) {
now = DateTime(2026, 11, 1 + d, 12);
picks.add(p.suggestion!.id);
}
expect(picks.toSet().length, greaterThan(2));
final steps = [
for (var i = 1; i < picks.length; i++)
(int.parse(picks[i].substring(1)) - int.parse(picks[i - 1].substring(1))) % 4,
];
expect(steps.every((s) => s == 1), isFalse);
p.dispose();
});
test('"Andere Idee" holds until midnight only', () async {
now = DateTime(2026, 10, 20, 18);
final p = await provider();
p.shuffleSuggestion();
final shuffled = p.suggestion!.id;
now = DateTime(2026, 10, 20, 23, 0);
expect(p.suggestion!.id, shuffled);
now = DateTime(2026, 10, 21, 8);
final p2 = await provider();
expect(p.suggestion!.id, p2.suggestion!.id);
p.dispose();
p2.dispose();
});
test('"Andere Idee" always picks a different Recipe', () async {
final p = await provider();
for (var i = 0; i < 10; i++) {
final before = p.suggestion!.id;
p.shuffleSuggestion();
expect(p.suggestion!.id, isNot(before));
}
p.dispose();
});
});
group('time format', () {
final l10n = AppLocalizationsDe();
test('formatMinutes', () {
expect(formatMinutes(l10n, 45), '45 Min.');
expect(formatMinutes(l10n, 60), '1 Std.');
expect(formatMinutes(l10n, 70), '1 Std. 10 Min.');
});
test('greeting follows the time of day', () {
final cases = {
(2, 0): 'Schönen Abend',
(5, 0): 'Guten Morgen',
(11, 29): 'Guten Morgen',
(11, 30): 'Mahlzeit!',
(13, 59): 'Mahlzeit!',
(14, 0): 'Guten Tag',
(17, 0): 'Guten Abend',
(21, 59): 'Guten Abend',
(22, 0): 'Schönen Abend',
};
cases.forEach((time, expected) {
final (hour, minute) = time;
expect(greeting(l10n, DateTime(2026, 1, 1, hour, minute)), expected, reason: '$hour:$minute');
});
});
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:cookit/src/recipes/text_breaks.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
String shown(String text) => withNaturalBreaks(text).replaceAll('​', '|');
test('breaks after hyphens, ampersands and slashes, and between words written together', () {
expect(shown('ApfelWeißkohlPfanne&Schupfi'), 'Apfel|Weißkohl|Pfanne&|Schupfi');
expect(shown('Brokkoli-Sahne-Nudeln'), 'Brokkoli-|Sahne-|Nudeln');
expect(shown('AvocadoMinze Smoothie'), 'Avocado|Minze Smoothie');
expect(shown('Salz/Pfeffer'), 'Salz/|Pfeffer');
expect(shown('Öl-Ärger'), 'Öl-|Ärger');
});
test('leaves ordinary names and abbreviations alone', () {
expect(shown('Tomatensoße mit Spätzle'), 'Tomatensoße mit Spätzle');
expect(shown('BBQ Burger'), 'BBQ Burger');
expect(shown('Gef. Hähnchenfilet'), 'Gef. Hähnchenfilet');
expect(shown('Rösti - knusprig'), 'Rösti - knusprig');
expect(withNaturalBreaks('ApfelWeißkohl').replaceAll('​', ''), 'ApfelWeißkohl');
});
test('a line now ends at a natural break, not mid-word', () {
// Test font: every glyph is fontSize wide. Room for 15 glyphs.
List<double> lineWidths(String text) {
final p = TextPainter(
text: TextSpan(text: text, style: const TextStyle(fontSize: 10)),
textDirection: TextDirection.ltr,
)..layout(maxWidth: 155);
return [for (final l in p.computeLineMetrics()) l.width];
}
expect(lineWidths('ApfelWeißkohlPfanne'), [150, 40]); // "ApfelWeißkohlPf" | "anne"
expect(lineWidths(withNaturalBreaks('ApfelWeißkohlPfanne')), [130, 60]); // "ApfelWeißkohl" | "Pfanne"
});
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:io';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:cookit/src/recipes/models/unit.dart';
import 'package:cookit/src/recipes/photo_store.dart';
import 'package:cookit/src/recipes/recipe_repository.dart';
import 'package:cookit/src/recipes/unit_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_database.dart';
void main() {
late AppDatabase db;
late PhotoStore photos;
late UnitRepository units;
late RecipeRepository recipes;
setUp(() async {
db = testDatabase();
photos = await tempPhotoStore();
units = UnitRepository(db);
recipes = RecipeRepository(db, photos);
});
tearDown(() async {
await db.close();
await photos.directory.delete(recursive: true);
});
test('a fresh database lists every built-in unit, the common ones offered directly', () async {
final all = await units.allUnits();
expect(all.map((u) => u.key), Unit.values.map((u) => u.key));
expect(all.where((u) => u.direct).map((u) => u.key), [
for (final u in Unit.values)
if (u.common) u.key,
]);
expect(all.every((u) => u.builtIn), isTrue);
});
test('own units can be added, renamed, offered directly or not, and sorted', () async {
final cube = await units.addOwn('Würfel');
expect(cube.key, startsWith('own-'));
await units.rename(cube.key, 'Brühwürfel');
await units.setDirect(cube.key, false);
await units.setDirect('gram', false);
final all = await units.allUnits();
final reordered = [all.last, ...all.take(all.length - 1)];
await units.reorder(reordered);
final after = await units.allUnits();
expect(after.first.key, cube.key);
expect((after.first.label, after.first.direct, after.first.builtIn), ('Brühwürfel', false, false));
expect(after.firstWhere((u) => u.key == 'gram').direct, isFalse);
});
test('built-in units keep their name', () async {
await units.rename('gram', 'Gramm!');
expect((await units.allUnits()).firstWhere((u) => u.key == 'gram').label, isNull);
});
test('deleting an own unit moves its name into the Ingredient Lines that used it', () async {
final cube = await units.addOwn('Würfel');
await recipes.saveRecipe(
recipe(
ingredientGroups: [
IngredientGroup(
lines: [
IngredientLine(amount: 2, unitKey: cube.key, name: 'Brühe'),
const IngredientLine(amount: 100, unitKey: 'gram', name: 'Mehl'),
],
),
],
),
);
await recipes.saveRecipe(recipe(id: 'r2', name: 'Ohne Würfel'));
expect(await units.recipeCount(cube.key), 1);
await units.deleteOwn(cube);
final lines = (await recipes.getRecipe('r1'))!.ingredientGroups.single.lines;
expect((lines[0].amount, lines[0].unitKey, lines[0].name), (2, null, 'Würfel Brühe'));
expect((lines[1].unitKey, lines[1].name), ('gram', 'Mehl'));
expect((await units.allUnits()).any((u) => u.key == cube.key), isFalse);
});
test('an existing version 1 database gets the units table on upgrade', () async {
await db.close();
final dir = await Directory.systemTemp.createTemp('cookit_db_');
final file = File('${dir.path}/cookit.sqlite');
var fileDb = testDatabase(file: file);
await RecipeRepository(fileDb, photos).saveRecipe(recipe());
// Make it look like the database before units existed.
await fileDb.customStatement('DROP TABLE units');
await fileDb.customStatement('PRAGMA user_version = 1');
await fileDb.close();
fileDb = testDatabase(file: file);
expect(await UnitRepository(fileDb).allUnits(), hasLength(Unit.values.length));
expect((await RecipeRepository(fileDb, photos).getRecipe('r1'))!.name, 'Focaccia');
await fileDb.close();
await dir.delete(recursive: true);
db = testDatabase(); // for tearDown
});
}