tane/apps/app_seeds/test/ui/draft_ocr_test.dart
vjrj 6809dc6143 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.
2026-07-09 21:23:46 +02:00

95 lines
3.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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