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().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)); }); }