165 lines
6.1 KiB
Dart
165 lines
6.1 KiB
Dart
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)),
|
||
|
|
);
|
||
|
|
}
|