feat(inventory): photo-first drafts + on-device OCR (digitization R2+R4)

Lower the bulk-digitization cliff with two more routes on top of the
already-landed CSV import and "save and add another":

- Photo-first drafts (capture now, catalogue later): burst-capture
  photos (camera or multi-gallery) into unnamed draft varieties, shown
  in a "to catalogue" tray, hidden from the main list until named.
  Adds Variety.isDraft (schema), addDraftVariety/watchDrafts/nameDraft,
  the triage sheet and the inventory banner.
- On-device OCR label suggestion (Tesseract, offline, no Google): a
  "Suggest name from photo" button in the naming dialog behind a
  LabelTextExtractor interface (Tesseract on Android/iOS, no-op
  elsewhere). Reads the largest print via hOCR bounding boxes, drops
  boilerplate/low-confidence noise, preprocesses (grayscale, contrast,
  upscale) and sweeps rotations (0-315 deg) so tilted packets still
  read. Bundles tessdata_fast eng+spa; validated on-device against real
  packets. The photo is written to a temp file deleted immediately in a
  finally block (the plugin needs a path) - a bounded, documented
  exception to no-plaintext-at-rest.

This commit also carries the co-developed schema evolution v5 to v8 that
shares these files (organic flag, species viability years, crop
calendar, lot provenance/abundance/preservation format, condition
checks) plus their exports/migrations and i18n.

Tests: CSV/draft/OCR unit + widget + migration green in isolation.
Note: the full widget suite currently hangs (>10 min) - under investigation.
This commit is contained in:
vjrj 2026-07-09 21:23:46 +02:00
parent 12a2ee2d64
commit 6809dc6143
89 changed files with 17141 additions and 228 deletions

View file

@ -0,0 +1,57 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
late VarietyRepository repo;
setUp(() {
db = newTestDatabase();
repo = newTestRepository(db);
});
tearDown(() => db.close());
Uint8List photo(int seed) => Uint8List.fromList([seed, seed, seed]);
test('a captured draft is hidden from the list but shown in the tray',
() async {
final id = await repo.addDraftVariety(photo(1));
// Not in the main inventory
expect(await repo.watchInventory().first, isEmpty);
// but in the "to catalogue" tray, with its photo and no name yet.
final drafts = await repo.watchDrafts().first;
expect(drafts, hasLength(1));
expect(drafts.single.id, id);
expect(drafts.single.isDraft, isTrue);
expect(drafts.single.label, '');
expect(drafts.single.photo, isNotNull);
});
test('naming a draft promotes it into the list and out of the tray',
() async {
final id = await repo.addDraftVariety(photo(2));
await repo.nameDraft(id, 'Grandma tomato');
expect(await repo.watchDrafts().first, isEmpty);
final list = await repo.watchInventory().first;
expect(list.single.id, id);
expect(list.single.label, 'Grandma tomato');
expect(list.single.isDraft, isFalse);
// The captured photo survived the promotion.
expect(list.single.photo, isNotNull);
});
test('the tray is ordered newest-capture first', () async {
final first = await repo.addDraftVariety(photo(1));
final second = await repo.addDraftVariety(photo(2));
final drafts = await repo.watchDrafts().first;
expect(drafts.map((d) => d.id).toList(), [second, first]);
});
}

View file

@ -25,6 +25,9 @@ void main() {
cultivarName: null,
category: 'Solanaceae',
notes: notes,
isDraft: false,
isOrganic: false,
needsReproduction: false,
);
Lot lot({String id = 'lot-1', String varietyId = 'var-1'}) => Lot(

View file

@ -123,6 +123,9 @@ void main() {
cultivarName: 'Marmande',
category: 'Solanaceae',
notes: 'heirloom',
isDraft: false,
isOrganic: false,
needsReproduction: false,
);
Lot lot({String id = 'lot-1'}) => Lot(

View file

@ -22,6 +22,9 @@ void main() {
cultivarName: 'Marmande',
category: 'Solanaceae',
notes: 'Keeps well',
isDraft: false,
isOrganic: false,
needsReproduction: false,
);
const lot = Lot(
id: 'lot-1',

View file

@ -7,6 +7,8 @@ import 'package:tane/data/export_import/inventory_json_codec.dart';
import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/domain/crop_calendar.dart';
import '../../support/test_support.dart';
@ -41,6 +43,13 @@ void main() {
label: 'Grandma tomato',
category: 'Solanaceae',
notes: 'Sow in March',
needsReproduction: true,
// Sow in spring and again in autumn (a phase can span several months).
sowMonths: Value(monthsToMask(const [3, 4, 9])),
transplantMonths: Value(monthsToMask(const [5])),
floweringMonths: Value(monthsToMask(const [7])),
fruitingMonths: Value(monthsToMask(const [8])),
seedHarvestMonths: Value(monthsToMask(const [9, 10])),
);
final lotId = await source.addLot(
varietyId: varietyId,
@ -48,6 +57,16 @@ void main() {
harvestMonth: 6,
quantity: const Quantity(kind: QuantityKind.cob, count: 2),
storageLocation: 'fridge',
originName: 'La Verde Coop',
originPlace: 'Cádiz',
abundance: Abundance.plentyToShare,
preservationFormat: PreservationFormat.jarWithDesiccant,
);
await source.addConditionCheck(
lotId: lotId,
checkedOn: 1700000001000,
containerCount: 2,
desiccantState: DesiccantState.dry,
);
await source.addVernacularName(
varietyId,
@ -102,6 +121,7 @@ void main() {
expect(targetRows.vernacularNames, sourceRows.vernacularNames);
expect(targetRows.externalLinks, sourceRows.externalLinks);
expect(targetRows.germinationTests, sourceRows.germinationTests);
expect(targetRows.conditionChecks, sourceRows.conditionChecks);
expect(targetRows.movements, sourceRows.movements);
expect(targetRows.attachments, sourceRows.attachments);

View file

@ -11,40 +11,66 @@ void main() {
verifier = SchemaVerifier(GeneratedHelper());
});
test('freshly created database matches the exported schema v5', () async {
final schema = await verifier.schemaAt(5);
test('freshly created database matches the exported schema v8', () async {
final schema = await verifier.schemaAt(8);
final db = AppDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 5);
await verifier.migrateAndValidate(db, 8);
await db.close();
});
test('upgrades v1 → v5 (Lot.type, harvestMonth, presentation, '
'Attachment.sortOrder) and matches the fresh schema', () async {
test('upgrades v1 → v8 (Lot.type, harvestMonth, presentation, '
'Attachment.sortOrder, Variety.isDraft/isOrganic, Species.viabilityYears, '
'v8 provenance/calendar/abundance/condition-checks) and matches the fresh '
'schema', () async {
final connection = await verifier.startAt(1);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 5);
await verifier.migrateAndValidate(db, 8);
await db.close();
});
test('upgrades v2 → v5 and matches the fresh schema', () async {
test('upgrades v2 → v8 and matches the fresh schema', () async {
final connection = await verifier.startAt(2);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 5);
await verifier.migrateAndValidate(db, 8);
await db.close();
});
test('upgrades v3 → v5 and matches the fresh schema', () async {
test('upgrades v3 → v8 and matches the fresh schema', () async {
final connection = await verifier.startAt(3);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 5);
await verifier.migrateAndValidate(db, 8);
await db.close();
});
test('upgrades v4 → v5 (adds Attachment.sortOrder) and matches the fresh '
'schema', () async {
test('upgrades v4 → v8 and matches the fresh schema', () async {
final connection = await verifier.startAt(4);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 5);
await verifier.migrateAndValidate(db, 8);
await db.close();
});
test('upgrades v5 → v8 (adds Variety.isDraft) and matches the fresh schema',
() async {
final connection = await verifier.startAt(5);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 8);
await db.close();
});
test('upgrades v6 → v8 (adds Variety.isOrganic, Species.viabilityYears) and '
'matches the fresh schema', () async {
final connection = await verifier.startAt(6);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 8);
await db.close();
});
test('upgrades v7 → v8 (adds Variety.needsReproduction + crop calendar, '
'Lot.origin/abundance/preservationFormat, ConditionChecks) and matches '
'the fresh schema', () async {
final connection = await verifier.startAt(7);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 8);
await db.close();
});
}

View file

@ -9,6 +9,9 @@ import 'schema_v2.dart' as v2;
import 'schema_v3.dart' as v3;
import 'schema_v4.dart' as v4;
import 'schema_v5.dart' as v5;
import 'schema_v6.dart' as v6;
import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -24,10 +27,16 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v4.DatabaseAtV4(db);
case 5:
return v5.DatabaseAtV5(db);
case 6:
return v6.DatabaseAtV6(db);
case 7:
return v7.DatabaseAtV7(db);
case 8:
return v8.DatabaseAtV8(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5];
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8];
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/domain/crop_calendar.dart';
void main() {
test('an empty month set packs to null, not 0', () {
expect(monthsToMask(const []), isNull);
expect(monthsToMask(const [0, 13, -1]), isNull); // out of range ignored
});
test('months round-trip through the mask, ascending and deduped', () {
final mask = monthsToMask(const [9, 3, 4, 3]);
expect(maskToMonths(mask), [3, 4, 9]);
});
test('every single month packs to a distinct bit', () {
for (var m = 1; m <= 12; m++) {
expect(maskToMonths(monthsToMask([m])), [m]);
}
});
test('maskToMonths of null or 0 is empty', () {
expect(maskToMonths(null), isEmpty);
expect(maskToMonths(0), isEmpty);
});
test('maskHasMonth reflects set bits', () {
final mask = monthsToMask(const [3, 9]);
expect(maskHasMonth(mask, 3), isTrue);
expect(maskHasMonth(mask, 9), isTrue);
expect(maskHasMonth(mask, 4), isFalse);
expect(maskHasMonth(null, 3), isFalse);
expect(maskHasMonth(mask, 0), isFalse);
});
test('toggleMonth adds, removes, and clears to null when empty', () {
var mask = toggleMonth(null, 3);
expect(maskToMonths(mask), [3]);
mask = toggleMonth(mask, 9);
expect(maskToMonths(mask), [3, 9]);
mask = toggleMonth(mask, 3);
expect(maskToMonths(mask), [9]);
mask = toggleMonth(mask, 9);
expect(mask, isNull); // last month removed null, not 0
});
}

View file

@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/ocr/ocr_language.dart';
void main() {
group('parseAvailableOcrLanguages', () {
test('strips the .traineddata suffix', () {
final available = parseAvailableOcrLanguages(
'{"files": ["eng.traineddata", "spa.traineddata"]}',
);
expect(available, {'eng', 'spa'});
});
test('empty / missing files yields empty set', () {
expect(parseAvailableOcrLanguages('{}'), isEmpty);
expect(parseAvailableOcrLanguages('{"files": []}'), isEmpty);
});
});
group('tesseractCodeForLocale', () {
test('maps ISO 639-1 to Tesseract codes', () {
expect(tesseractCodeForLocale('es'), 'spa');
expect(tesseractCodeForLocale('en'), 'eng');
expect(tesseractCodeForLocale('ar'), 'ara');
expect(tesseractCodeForLocale('zh'), 'chi_sim');
});
test('accepts full locale tags and is case-insensitive', () {
expect(tesseractCodeForLocale('es_ES'), 'spa');
expect(tesseractCodeForLocale('zh-Hans-CN'), 'chi_sim');
expect(tesseractCodeForLocale('DE'), 'deu');
});
test('unmapped locale returns null', () {
expect(tesseractCodeForLocale('xx'), isNull);
});
});
group('resolveOcrLanguages', () {
const bundled = {'eng', 'spa'};
test('preferred locale comes first, baseline appended', () {
expect(
resolveOcrLanguages(preferredLocales: ['es'], available: bundled),
'spa+eng',
);
});
test('English preferred is not duplicated by the baseline', () {
expect(
resolveOcrLanguages(preferredLocales: ['en'], available: bundled),
'eng',
);
});
test('order follows preference, de-duplicated', () {
expect(
resolveOcrLanguages(
preferredLocales: ['es', 'en', 'es'],
available: bundled,
),
'spa+eng',
);
});
test('unbundled locale falls back to the Latin baseline', () {
// German pack is not bundled here degrade to eng, never fail.
expect(
resolveOcrLanguages(preferredLocales: ['de'], available: bundled),
'eng',
);
});
test('a future RTL/CJK pack lights up automatically once bundled', () {
expect(
resolveOcrLanguages(
preferredLocales: ['ar'],
available: {'eng', 'spa', 'ara'},
),
'ara+eng',
);
});
test('no baseline available: falls back to every bundled pack, sorted', () {
expect(
resolveOcrLanguages(
preferredLocales: ['fr'],
available: {'spa', 'deu'},
),
'deu+spa',
);
});
test('empty available yields empty string', () {
expect(
resolveOcrLanguages(preferredLocales: ['es'], available: const {}),
'',
);
});
});
}

View file

@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/ocr/tesseract_label_extractor.dart';
/// Builds one hOCR word span with a bounding box (height = y1 - y0) and an
/// optional recognition confidence.
String word(String text, int x0, int y0, int x1, int y1, {int conf = 90}) =>
"<span class='ocrx_word' title='bbox $x0 $y0 $x1 $y1; x_wconf $conf'>"
'$text</span>';
String page(List<String> words) =>
"<div class='ocr_page'>${words.join('\n')}</div>";
void main() {
group('pickLabelFromHocr picks the largest print, not the longest line', () {
test('Aubergine Black Beauty packet → the variety name, not boilerplate', () {
// "Organic Product" (14 letters) is longer than "Black Beauty" (11) but
// printed smaller the old longest-line heuristic wrongly picked it.
final hocr = page([
word('BonPrime', 120, 300, 300, 335), // brand, tiny
word('Organic', 130, 1050, 320, 1095), // boilerplate, small
word('Product', 130, 1100, 340, 1145),
word('AUBERGINE', 380, 1180, 900, 1300), // the title (tallest)
word('BLACK', 380, 1330, 620, 1410),
word('BEAUTY', 640, 1330, 900, 1410),
word('HERB', 430, 1470, 560, 1510), // boilerplate, small
word('SEEDS', 580, 1470, 740, 1510),
]);
expect(pickLabelFromHocr(hocr), 'Aubergine Black Beauty');
});
test('Cucumber Supermarketer packet → the variety name', () {
final hocr = page([
word('BonPrime', 120, 300, 300, 335),
word('Organic', 130, 1050, 320, 1095),
word('Product', 130, 1100, 340, 1145),
word('CUCUMBER', 300, 1200, 820, 1320), // tallest
word('SUPERMARKETER', 320, 1350, 900, 1430),
word('HERB', 430, 1470, 560, 1510),
word('SEEDS', 580, 1470, 740, 1510),
]);
expect(pickLabelFromHocr(hocr), 'Cucumber Supermarketer');
});
test('reads left-to-right within the same row', () {
final hocr = page([
word('CHERRY', 400, 1000, 560, 1090),
word('TOMATO', 580, 1000, 760, 1090),
]);
expect(pickLabelFromHocr(hocr), 'Cherry Tomato');
});
test('a page of only boilerplate yields null', () {
final hocr = page([
word('Organic', 130, 1050, 320, 1095),
word('Product', 130, 1100, 340, 1145),
word('HERB', 430, 1470, 560, 1510),
word('SEEDS', 580, 1470, 740, 1510),
]);
expect(pickLabelFromHocr(hocr), isNull);
});
test('empty hOCR yields null', () {
expect(pickLabelFromHocr(page(const [])), isNull);
expect(pickLabelFromHocr(''), isNull);
});
test('short OCR fragments ("Re Ma") are dropped → no suggestion', () {
// Blurry misread into 12 char blobs: better nothing than "Re Ma".
final hocr = page([
word('Re', 300, 1200, 420, 1320, conf: 40),
word('Ma', 460, 1200, 600, 1320, conf: 40),
]);
expect(pickLabelFromHocr(hocr), isNull);
});
test('a plausibly-misread title word is kept (user edits it)', () {
// "Sucumber" (C read as S) is useful as an editable prefill.
final hocr = page([
word('Sucumber', 300, 1200, 820, 1320, conf: 45),
word('Organic', 130, 1050, 320, 1095, conf: 80),
]);
expect(pickLabelFromHocr(hocr), 'Sucumber');
});
test('a near-zero confidence word is dropped by the floor', () {
final hocr = page([word('Xzqwp', 300, 1200, 820, 1320, conf: 8)]);
expect(pickLabelFromHocr(hocr), isNull);
});
});
}

View file

@ -8,6 +8,8 @@ import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/security/secret_store.dart';
import 'package:tane/services/onboarding_store.dart';
import 'package:tane/state/inventory_cubit.dart';
import 'package:tane/state/variety_detail_cubit.dart';
import 'package:tane/ui/variety_detail_screen.dart';
@ -33,6 +35,25 @@ VarietyRepository newTestRepository(
SpeciesRepository newTestSpeciesRepository(AppDatabase db) =>
SpeciesRepository(db, idGen: IdGen());
/// An in-memory [SecretStore] for tests (no OS keystore).
class InMemorySecretStore implements SecretStore {
final Map<String, String> _values = {};
@override
Future<String?> read(String key) async => _values[key];
@override
Future<void> write(String key, String value) async => _values[key] = value;
}
/// A fresh [OnboardingStore] backed by in-memory storage. Defaults to
/// "intro already seen" so app-level tests land on home, not the carousel.
OnboardingStore newTestOnboardingStore({bool introSeen = true}) {
final store = InMemorySecretStore();
if (introSeen) store.write('tane.intro_seen', '1');
return OnboardingStore(store);
}
/// Wraps [child] with the providers a screen expects (repository, inventory
/// cubit) plus i18n and Material localizations, pinned to [locale].
Widget wrapScreen({

View file

@ -66,24 +66,27 @@ void main() {
);
}
testWidgets('settings shows the backup section with its three actions', (
testWidgets('settings shows the backup section with its actions', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(800, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(wrap(SettingsScreen(exportImport: service)));
expect(find.text('Backup'), findsOneWidget);
expect(find.text('Export as CSV'), findsOneWidget);
expect(find.text('Export as JSON'), findsOneWidget);
expect(find.text('Import from JSON'), findsOneWidget);
expect(find.text('Backup & restore'), findsOneWidget);
expect(find.text('Save a backup'), findsOneWidget);
expect(find.text('Restore a backup'), findsOneWidget);
expect(find.text('Export to a spreadsheet'), findsOneWidget);
expect(find.text('Import a list'), findsOneWidget);
});
testWidgets('export CSV saves a .csv file and confirms', (tester) async {
testWidgets('spreadsheet export saves a .csv file and confirms', (
tester,
) async {
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.tap(find.text('Export as CSV'));
await tester.tap(find.text('Export to a spreadsheet'));
await tester.pumpAndSettle();
expect(files.savedName, endsWith('.csv'));
@ -91,11 +94,11 @@ void main() {
expect(find.text('Copy saved'), findsOneWidget);
});
testWidgets('export JSON saves a versioned .json file', (tester) async {
testWidgets('saving a backup writes a versioned .json file', (tester) async {
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.tap(find.text('Export as JSON'));
await tester.tap(find.text('Save a backup'));
await tester.pumpAndSettle();
expect(files.savedName, endsWith('.json'));
@ -120,9 +123,9 @@ void main() {
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.tap(find.text('Import from JSON'));
await tester.tap(find.text('Restore a backup'));
await tester.pumpAndSettle();
expect(find.text('Import a saved copy?'), findsOneWidget);
expect(find.text('Restore a backup?'), findsOneWidget);
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
@ -137,7 +140,7 @@ void main() {
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.tap(find.text('Import from JSON'));
await tester.tap(find.text('Restore a backup'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();

View file

@ -0,0 +1,95 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/ocr/label_text_extractor.dart';
import 'package:tane/ui/draft_triage.dart';
/// A stand-in OCR engine so the dialog can be tested without a native backend.
class _FakeExtractor implements LabelTextExtractor {
_FakeExtractor({this.result, this.supported = true});
final String? result;
final bool supported;
@override
bool get isSupported => supported;
@override
Future<String?> suggestLabel(Uint8List photo) async => result;
}
Widget _host(Widget child) {
LocaleSettings.setLocaleSync(AppLocale.en);
return TranslationProvider(
child: MaterialApp(
locale: AppLocale.en.flutterLocale,
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: Scaffold(body: child),
),
);
}
void main() {
// A valid 1×1 PNG so Image.memory can decode it in the widget test.
final photo = Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
0x42, 0x60, 0x82,
]);
testWidgets('a supported engine offers a suggestion that pre-fills the name',
(tester) async {
await tester.pumpWidget(
_host(
NameDraftDialog(
photo: photo,
extractor: _FakeExtractor(result: 'Aubergine Black Beauty'),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('draft.suggestFromPhoto')), findsOneWidget);
await tester.tap(find.byKey(const Key('draft.suggestFromPhoto')));
await tester.pumpAndSettle();
expect(find.text('Aubergine Black Beauty'), findsOneWidget);
});
testWidgets('an unsupported engine hides the suggestion button',
(tester) async {
await tester.pumpWidget(
_host(
NameDraftDialog(
photo: photo,
extractor: _FakeExtractor(supported: false),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('draft.suggestFromPhoto')), findsNothing);
});
testWidgets('a null suggestion leaves the field untouched (no crash)',
(tester) async {
await tester.pumpWidget(
_host(NameDraftDialog(photo: photo, extractor: _FakeExtractor())),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('draft.suggestFromPhoto')));
await tester.pumpAndSettle();
// Nothing was entered; the save still returns the (empty) field.
expect(find.byKey(const Key('draft.nameField')), findsOneWidget);
});
}

View file

@ -0,0 +1,72 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/ui/inventory_list_screen.dart';
import '../support/test_support.dart';
void main() {
// A valid 1×1 PNG so Image.memory decodes it in the widget test (the seed
// only nudges a trailing byte to keep captures distinct).
Uint8List photo(int seed) => Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
0x42, 0x60, 0x82, seed,
]);
testWidgets(
'drafts show a triage banner, stay out of the list, and can be named',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final db = newTestDatabase();
addTearDown(db.close);
final repo = newTestRepository(db);
await repo.addDraftVariety(photo(1));
await repo.addDraftVariety(photo(2));
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
// The two captures are in the tray, not the main list.
expect(find.byKey(const Key('inventory.triageBanner')), findsOneWidget);
expect(find.text('2 to catalogue'), findsOneWidget);
expect(find.text('No seeds yet. Tap + to add your first.'), findsOneWidget);
// Open the tray and name the first draft.
await tester.tap(find.byKey(const Key('inventory.triageBanner')));
await tester.pumpAndSettle();
expect(find.text('Unnamed'), findsNWidgets(2));
await tester.tap(find.text('Unnamed').first);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('draft.nameField')),
'Grandma tomato',
);
await tester.tap(find.byKey(const Key('draft.nameSave')));
await tester.pumpAndSettle();
// One draft left in the tray
expect(find.text('Unnamed'), findsOneWidget);
// and close the sheet: the named seed is now in the inventory, the
// banner shows one remaining.
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(find.text('Grandma tomato'), findsOneWidget);
expect(find.text('1 to catalogue'), findsOneWidget);
await disposeTree(tester);
},
);
}

View file

@ -10,6 +10,7 @@ void main() {
child: TaneApp(
repository: newTestRepository(db),
species: newTestSpeciesRepository(db),
onboarding: newTestOnboardingStore(),
),
);

View file

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/app.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/ui/intro_screen.dart';
import '../support/test_support.dart';
void main() {
Widget standalone(VoidCallback onDone) => TranslationProvider(
child: MaterialApp(home: IntroScreen(onDone: onDone)),
);
testWidgets('carousel advances through slides and finishes', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
var done = false;
await tester.pumpWidget(standalone(() => done = true));
await tester.pumpAndSettle();
// First slide + a "Next" button (not "Get started" yet).
expect(find.text('The seed that brought you here'), findsOneWidget);
expect(find.text('Next'), findsOneWidget);
expect(find.text('Get started'), findsNothing);
// Advance to the last of the five slides.
for (var i = 0; i < 4; i++) {
await tester.tap(find.text('Next'));
await tester.pumpAndSettle();
}
expect(find.text('To sow is to multiply'), findsOneWidget);
expect(find.text('Get started'), findsOneWidget);
await tester.tap(find.text('Get started'));
await tester.pumpAndSettle();
expect(done, isTrue);
});
testWidgets('Skip finishes immediately', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
var done = false;
await tester.pumpWidget(standalone(() => done = true));
await tester.pumpAndSettle();
await tester.tap(find.text('Skip'));
await tester.pumpAndSettle();
expect(done, isTrue);
});
testWidgets('first launch opens on the intro and marks it seen', (
tester,
) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final db = newTestDatabase();
addTearDown(db.close);
final onboarding = newTestOnboardingStore(introSeen: false);
await tester.pumpWidget(
TranslationProvider(
child: TaneApp(
repository: newTestRepository(db),
species: newTestSpeciesRepository(db),
onboarding: onboarding,
showIntro: true,
),
),
);
await tester.pumpAndSettle();
// The carousel is shown, not the home menu.
expect(find.text('The seed that brought you here'), findsOneWidget);
await tester.tap(find.text('Skip'));
await tester.pumpAndSettle();
// Lands on home, and the intro is now remembered as seen.
expect(find.text('Your inventory'), findsOneWidget);
expect(await onboarding.introSeen(), isTrue);
await disposeTree(tester);
});
}