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