From b840b83c42cbe27b19860f252de0445da5713eaf Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 02:05:00 +0200 Subject: [PATCH] feat(species): auto-classify variety species from its label Infer the catalog species a free-text variety label names ("Maiz de la abuela" -> Zea mays) and prefill the category from the species family. - Pure, testable matcher (domain/species_autoclassify.dart): whole-word, accent/case-insensitive, Unicode-aware (any script), longest-name-wins, ambiguous names left unclassified, light plural fold. - Quick-add and draft naming auto-link the species when the field is empty (non-destructive; an explicit category is kept). - Edit sheet offers a one-tap suggestion from the typed name. Tests: matcher unit, repository integration, SpeciesRepository.classifyLabel, and an edit-sheet widget test. --- .../lib/data/species_repository.dart | 39 ++++++ .../lib/data/variety_repository.dart | 59 +++++++- .../lib/domain/species_autoclassify.dart | 128 ++++++++++++++++++ apps/app_seeds/lib/i18n/en.i18n.json | 1 + apps/app_seeds/lib/i18n/es.i18n.json | 1 + apps/app_seeds/lib/i18n/pt.i18n.json | 1 + apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 4 + apps/app_seeds/lib/i18n/strings_es.g.dart | 2 + apps/app_seeds/lib/i18n/strings_pt.g.dart | 2 + .../lib/ui/variety_detail_screen.dart | 31 +++++ .../test/data/species_repository_test.dart | 13 ++ .../test/data/variety_autoclassify_test.dart | 107 +++++++++++++++ .../domain/species_autoclassify_test.dart | 68 ++++++++++ .../test/ui/variety_detail_screen_test.dart | 53 ++++++++ 15 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 apps/app_seeds/lib/domain/species_autoclassify.dart create mode 100644 apps/app_seeds/test/data/variety_autoclassify_test.dart create mode 100644 apps/app_seeds/test/domain/species_autoclassify_test.dart diff --git a/apps/app_seeds/lib/data/species_repository.dart b/apps/app_seeds/lib/data/species_repository.dart index 87c0f92..d521bc2 100644 --- a/apps/app_seeds/lib/data/species_repository.dart +++ b/apps/app_seeds/lib/data/species_repository.dart @@ -2,6 +2,7 @@ import 'package:commons_core/commons_core.dart'; import 'package:drift/drift.dart'; import '../db/database.dart'; +import '../domain/species_autoclassify.dart'; /// One entry from the bundled catalog, before it is stored. class SpeciesSeed { @@ -249,6 +250,44 @@ class SpeciesRepository { .replaceAll('%', r'\%') .replaceAll('_', r'\_'); + /// Auto-classification for the UI: infers the single species a free-text + /// variety [label] names (see [matchSpeciesInLabel]) and returns it as a + /// [SpeciesMatch] with a best common name for [languageCode]. Returns null + /// when the label names no known species or the match is ambiguous — so the + /// caller can offer it as a one-tap suggestion, never a silent guess. + Future classifyLabel( + String label, { + String languageCode = 'en', + }) async { + final species = await (_db.select( + _db.species, + )..where((s) => s.isDeleted.equals(false))).get(); + final commons = await (_db.select( + _db.speciesCommonNames, + )..where((n) => n.isDeleted.equals(false))).get(); + + final names = [ + for (final s in species) + SpeciesNameEntry(speciesId: s.id, name: s.scientificName), + for (final c in commons) + SpeciesNameEntry(speciesId: c.speciesId, name: c.name), + ]; + final id = matchSpeciesInLabel(label, names); + if (id == null) return null; + + final match = species.firstWhere((s) => s.id == id); + final matchNames = [ + for (final c in commons) + if (c.speciesId == id) (name: c.name, language: c.language), + ]; + return SpeciesMatch( + id: match.id, + scientificName: match.scientificName, + family: match.family, + commonName: _bestName(matchNames, languageCode), + ); + } + String? _bestName( List<({String name, String? language})> names, String languageCode, diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index f8ff1cc..b350a06 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart'; import '../db/database.dart'; import '../db/enums.dart'; import '../domain/seed_viability.dart'; +import '../domain/species_autoclassify.dart'; import 'export_import/import_reconciler.dart'; import 'export_import/inventory_csv_codec.dart'; import 'export_import/inventory_snapshot.dart'; @@ -657,6 +658,11 @@ class VarietyRepository { /// The 20-second quick-add: a [label] (required) plus an optional [category], /// an optional [quantity] (creates a Lot) and an optional [photoBytes] /// (stored as an encrypted BLOB Attachment). Returns the new Variety id. + /// + /// If the label names a bundled species ("Maíz de la abuela" → *Zea mays*), + /// the variety is linked to it automatically and its category prefilled from + /// the species' family — the grower never has to open the species picker for + /// the common case. An explicit [category] is always kept as given. Future addQuickVariety({ required String label, String? category, @@ -665,6 +671,8 @@ class VarietyRepository { Uint8List? photoBytes, }) async { final varietyId = idGen.newId(); + final auto = await _autoClassifyFromLabel(label); + final resolvedCategory = _hasText(category) ? category : auto?.family; await _db.transaction(() async { final (created, updated) = await _stamp(); await _db @@ -676,7 +684,8 @@ class VarietyRepository { createdAt: created, updatedAt: updated, lastAuthor: nodeId, - category: Value(category), + category: Value(resolvedCategory), + speciesId: Value(auto?.id), ), ); @@ -762,12 +771,26 @@ class VarietyRepository { /// Names a draft and promotes it out of the "to catalogue" tray: sets its /// [label] and clears [isDraft] in one LWW write. + /// + /// Like [addQuickVariety], if the new [label] names a bundled species the + /// draft is auto-linked to it and its category prefilled from the family — + /// but only when the draft has neither yet, so it never overrides a species + /// or category the grower already set on the draft. Future nameDraft(String id, String label) async { final (_, updated) = await _stamp(); + final draft = await (_db.select( + _db.varieties, + )..where((v) => v.id.equals(id))).getSingleOrNull(); + final auto = draft?.speciesId == null + ? await _autoClassifyFromLabel(label) + : null; + final prefillCategory = auto != null && !_hasText(draft?.category); await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write( VarietiesCompanion( label: Value(label), isDraft: const Value(false), + speciesId: auto == null ? const Value.absent() : Value(auto.id), + category: prefillCategory ? Value(auto.family) : const Value.absent(), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), @@ -949,6 +972,40 @@ class VarietyRepository { ); } + /// Infers a catalog species from a free-text [label] (see + /// [matchSpeciesInLabel]). Returns the matched species' id and family, or null + /// when the label names no known species or the match is ambiguous. + Future<({String id, String? family})?> _autoClassifyFromLabel( + String label, + ) async { + final speciesId = matchSpeciesInLabel(label, await _speciesNamesForMatching()); + if (speciesId == null) return null; + final species = await (_db.select( + _db.species, + )..where((s) => s.id.equals(speciesId))).getSingleOrNull(); + return species == null ? null : (id: species.id, family: species.family); + } + + /// Every catalog name that can identify a species — its scientific name and + /// all non-deleted common names, across every bundled locale — flattened for + /// the language-agnostic matcher. The catalog is small, so this is cheap. + Future> _speciesNamesForMatching() async { + final species = await (_db.select( + _db.species, + )..where((s) => s.isDeleted.equals(false))).get(); + final commons = await (_db.select( + _db.speciesCommonNames, + )..where((n) => n.isDeleted.equals(false))).get(); + return [ + for (final s in species) + SpeciesNameEntry(speciesId: s.id, name: s.scientificName), + for (final c in commons) + SpeciesNameEntry(speciesId: c.speciesId, name: c.name), + ]; + } + + bool _hasText(String? value) => value != null && value.trim().isNotEmpty; + /// Links [varietyId] to a catalog [speciesId]. If the variety has no category /// yet, prefill it from the species' botanical family (data-model §6). Future linkSpecies(String varietyId, String speciesId) async { diff --git a/apps/app_seeds/lib/domain/species_autoclassify.dart b/apps/app_seeds/lib/domain/species_autoclassify.dart new file mode 100644 index 0000000..7736ef7 --- /dev/null +++ b/apps/app_seeds/lib/domain/species_autoclassify.dart @@ -0,0 +1,128 @@ +/// Auto-classification: infer the species a free-text variety label refers to, +/// so typing "Maíz de la abuela" can link *Zea mays* (and prefill the category) +/// without the grower opening the species picker. +/// +/// Pure domain logic (no DB, no Flutter) so it is trivially unit-testable. The +/// repository feeds it the bundled catalog names; matching is done here. +library; + +/// One catalog name that can identify a species: a common (vernacular) name or +/// the scientific name, paired with the species it belongs to. The matcher is +/// language-agnostic — it never assumes es/en — so the repository passes names +/// from every bundled locale at once. +class SpeciesNameEntry { + const SpeciesNameEntry({required this.speciesId, required this.name}); + + final String speciesId; + final String name; +} + +/// Detects the species a free-text variety [label] refers to by matching a +/// bundled [names] entry as a whole word inside the label — accent- and +/// case-insensitively. "Maíz de la abuela" → *Zea mays*; "Tomate cherry" → +/// *Solanum lycopersicum*. +/// +/// Returns the matched species id, or null when nothing matches or the best +/// match is ambiguous. The rules keep it from mislabelling: +/// * **Whole-word (token) match only**, so "pea" never fires on "peach" nor +/// "haba" on "habanero" — a common name must appear as its own word(s). +/// * **Longest name wins** (most specific): "broad bean" beats "bean". +/// * **Ambiguous → null**: if the winning name maps to more than one species +/// (e.g. "calabaza" spanning several squashes), it is left for the grower +/// to disambiguate rather than guessed. +/// +/// A light trailing-`s` fold lets a plural label word ("Tomates", "Habas") +/// still match a singular catalog name. It is an additive nudge for the many +/// languages that pluralise with `-s`, never an override of an exact match, and +/// it cannot introduce a match that a different word did not already carry. +String? matchSpeciesInLabel(String label, List names) { + final haystack = _tokenize(label); + if (haystack.isEmpty) return null; + + ({int tokens, int chars})? best; + final winners = {}; + for (final entry in names) { + final needle = _tokenize(entry.name); + if (!_containsSequence(haystack, needle)) continue; + final chars = needle.fold(0, (sum, t) => sum + t.length); + final score = (tokens: needle.length, chars: chars); + if (best == null || _isBetter(score, best!)) { + best = score; + winners + ..clear() + ..add(entry.speciesId); + } else if (score.tokens == best!.tokens && score.chars == best!.chars) { + winners.add(entry.speciesId); + } + } + + // A single species behind the best-scoring name(s) → confident; otherwise the + // name is shared across species, so leave it unclassified. + return winners.length == 1 ? winners.first : null; +} + +bool _isBetter(({int tokens, int chars}) a, ({int tokens, int chars}) b) => + a.tokens > b.tokens || (a.tokens == b.tokens && a.chars > b.chars); + +/// Lowercases, splits on any non-letter/non-digit boundary (Unicode-aware, so +/// non-Latin scripts survive), and folds Latin diacritics on each token. +List _tokenize(String s) => s + .toLowerCase() + .split(_wordBoundary) + .where((t) => t.isNotEmpty) + .map(_foldDiacritics) + .toList(growable: false); + +final RegExp _wordBoundary = RegExp(r'[^\p{L}\p{N}]+', unicode: true); + +/// True when [needle] appears as a contiguous run of words inside [haystack]. +bool _containsSequence(List haystack, List needle) { + if (needle.isEmpty || needle.length > haystack.length) return false; + for (var i = 0; i + needle.length <= haystack.length; i++) { + var matched = true; + for (var j = 0; j < needle.length; j++) { + if (!_wordsEqual(haystack[i + j], needle[j])) { + matched = false; + break; + } + } + if (matched) return true; + } + return false; +} + +bool _wordsEqual(String a, String b) => a == b || _singular(a) == _singular(b); + +/// Drops a single trailing `s` from words longer than three letters — a +/// deliberately crude plural fold (see [matchSpeciesInLabel]). +String _singular(String t) => + (t.length > 3 && t.endsWith('s')) ? t.substring(0, t.length - 1) : t; + +/// Folds common Latin diacritics to their base letter so "Maiz" matches "Maíz". +/// Only Latin marks are folded; other scripts pass through untouched, keeping +/// the matcher usable for any language. +String _foldDiacritics(String token) { + if (token.codeUnits.every((c) => c < 0x80)) return token; // fast path: ASCII + final buffer = StringBuffer(); + for (final ch in token.split('')) { + buffer.write(_diacriticFolds[ch] ?? ch); + } + return buffer.toString(); +} + +const Map _diacriticFolds = { + 'á': 'a', 'à': 'a', 'ä': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', + 'ă': 'a', 'ą': 'a', + 'é': 'e', 'è': 'e', 'ë': 'e', 'ê': 'e', 'ē': 'e', 'ė': 'e', 'ę': 'e', + 'ě': 'e', + 'í': 'i', 'ì': 'i', 'ï': 'i', 'î': 'i', 'ī': 'i', 'į': 'i', 'ı': 'i', + 'ó': 'o', 'ò': 'o', 'ö': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'ō': 'o', + 'ő': 'o', + 'ú': 'u', 'ù': 'u', 'ü': 'u', 'û': 'u', 'ū': 'u', 'ů': 'u', 'ű': 'u', + 'ñ': 'n', 'ń': 'n', 'ň': 'n', + 'ç': 'c', 'ć': 'c', 'č': 'c', + 'ş': 's', 'š': 's', 'ș': 's', 'ś': 's', + 'ž': 'z', 'ź': 'z', 'ż': 'z', + 'ý': 'y', 'ÿ': 'y', + 'ğ': 'g', 'ł': 'l', 'ð': 'd', 'þ': 'th', 'ß': 'ss', 'œ': 'oe', 'æ': 'ae', +}; diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index fef6edc..7c8d030 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -190,6 +190,7 @@ "notes": "Notes", "species": "Species (from catalog)", "speciesHint": "Search a species…", + "speciesSuggested": "Suggested from the name", "organic": "Organic", "organicHint": "Grown organically (eco)" }, diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 533a96b..8348d41 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -190,6 +190,7 @@ "notes": "Notas", "species": "Especie (del catálogo)", "speciesHint": "Buscar una especie…", + "speciesSuggested": "Sugerida por el nombre", "organic": "Ecológica", "organicHint": "Cultivada de forma ecológica (eco)" }, diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 9c7f66f..d782107 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -186,6 +186,7 @@ "notes": "Notas", "species": "Espécie (do catálogo)", "speciesHint": "Procura uma espécie…", + "speciesSuggested": "Sugerida pelo nome", "organic": "Biológica", "organicHint": "Cultivada em modo biológico" }, diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 8008f45..f51fd02 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 869 (289 per locale) +/// Strings: 872 (290 per locale) /// -/// Built on 2026-07-10 at 00:08 UTC +/// Built on 2026-07-10 at 00:13 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 6a80e41..f0459cc 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -630,6 +630,9 @@ class Translations$editVariety$en { /// en: 'Search a species…' String get speciesHint => 'Search a species…'; + /// en: 'Suggested from the name' + String get speciesSuggested => 'Suggested from the name'; + /// en: 'Organic' String get organic => 'Organic'; @@ -1642,6 +1645,7 @@ extension on Translations { 'editVariety.notes' => 'Notes', 'editVariety.species' => 'Species (from catalog)', 'editVariety.speciesHint' => 'Search a species…', + 'editVariety.speciesSuggested' => 'Suggested from the name', 'editVariety.organic' => 'Organic', 'editVariety.organicHint' => 'Grown organically (eco)', 'addLot.title' => 'Add lot', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index c23dc3c..ef7f69b 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -351,6 +351,7 @@ class _Translations$editVariety$es extends Translations$editVariety$en { @override String get notes => 'Notas'; @override String get species => 'Especie (del catálogo)'; @override String get speciesHint => 'Buscar una especie…'; + @override String get speciesSuggested => 'Sugerida por el nombre'; @override String get organic => 'Ecológica'; @override String get organicHint => 'Cultivada de forma ecológica (eco)'; } @@ -1080,6 +1081,7 @@ extension on TranslationsEs { 'editVariety.notes' => 'Notas', 'editVariety.species' => 'Especie (del catálogo)', 'editVariety.speciesHint' => 'Buscar una especie…', + 'editVariety.speciesSuggested' => 'Sugerida por el nombre', 'editVariety.organic' => 'Ecológica', 'editVariety.organicHint' => 'Cultivada de forma ecológica (eco)', 'addLot.title' => 'Añadir lote', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 2998e7d..905a3bc 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -347,6 +347,7 @@ class _Translations$editVariety$pt extends Translations$editVariety$en { @override String get notes => 'Notas'; @override String get species => 'Espécie (do catálogo)'; @override String get speciesHint => 'Procura uma espécie…'; + @override String get speciesSuggested => 'Sugerida pelo nome'; @override String get organic => 'Biológica'; @override String get organicHint => 'Cultivada em modo biológico'; } @@ -1072,6 +1073,7 @@ extension on TranslationsPt { 'editVariety.notes' => 'Notas', 'editVariety.species' => 'Espécie (do catálogo)', 'editVariety.speciesHint' => 'Procura uma espécie…', + 'editVariety.speciesSuggested' => 'Sugerida pelo nome', 'editVariety.organic' => 'Biológica', 'editVariety.organicHint' => 'Cultivada em modo biológico', 'addLot.title' => 'Adicionar lote', diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index b195c83..8dd01cb 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -605,6 +605,11 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { List _suggestions = const []; String? _pickedSpeciesId; + + /// A species inferred from the typed name, offered as a one-tap suggestion. + /// Only surfaced while the variety has no species and the grower has not + /// picked one, so it never nags over a deliberate choice. + SpeciesMatch? _suggested; late bool _isOrganic = widget.detail.isOrganic; late bool _needsReproduction = widget.detail.needsReproduction; late int? _sowMonths = widget.detail.sowMonths; @@ -626,14 +631,26 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { Future _onSpeciesQuery(String query) async { final lang = Localizations.localeOf(context).languageCode; final results = await widget.species.search(query, languageCode: lang); + // A manual species search takes over from the name-based suggestion. if (mounted) setState(() => _suggestions = results); } + /// As the name is typed, offer the species it names — but only when the + /// variety has no species yet and the grower hasn't picked one, so an + /// existing link is never second-guessed. + Future _onNameChanged(String name) async { + if (widget.detail.speciesId != null || _pickedSpeciesId != null) return; + final lang = Localizations.localeOf(context).languageCode; + final match = await widget.species.classifyLabel(name, languageCode: lang); + if (mounted) setState(() => _suggested = match); + } + void _pick(SpeciesMatch match) { setState(() { _pickedSpeciesId = match.id; _speciesField.text = match.displayLabel; _suggestions = const []; + _suggested = null; }); } @@ -694,6 +711,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { borderRadius: BorderRadius.all(Radius.circular(8)), ), ), + onChanged: _onNameChanged, ), const SizedBox(height: 12), TextField( @@ -719,6 +737,19 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { : Text(match.family!), onTap: () => _pick(match), ), + // One-tap suggestion inferred from the name (only when the + // species field is idle, so it never competes with a manual + // search). + if (_suggested case final suggested? + when _suggestions.isEmpty && _pickedSpeciesId == null) + ListTile( + dense: true, + key: const Key('editVariety.speciesSuggestion'), + leading: const Icon(Icons.auto_awesome_outlined), + title: Text(suggested.displayLabel), + subtitle: Text(t.editVariety.speciesSuggested), + onTap: () => _pick(suggested), + ), const SizedBox(height: 12), TextField( controller: _category, diff --git a/apps/app_seeds/test/data/species_repository_test.dart b/apps/app_seeds/test/data/species_repository_test.dart index 74ac6be..bb862e4 100644 --- a/apps/app_seeds/test/data/species_repository_test.dart +++ b/apps/app_seeds/test/data/species_repository_test.dart @@ -224,4 +224,17 @@ void main() { final results = await repo.search('Genus', limit: 5); expect(results.length, 5); }); + + test('classifyLabel infers the species named in a free-text label', () async { + await repo.seedBundled(_seeds); + + final match = await repo.classifyLabel('Tomate cherry', languageCode: 'es'); + expect(match, isNotNull); + expect(match!.scientificName, 'Solanum lycopersicum'); + expect(match.family, 'Solanaceae'); + expect(match.commonName, 'Tomate'); // best name for the locale + + // No species named → no suggestion. + expect(await repo.classifyLabel('Girasol gigante'), isNull); + }); } diff --git a/apps/app_seeds/test/data/variety_autoclassify_test.dart b/apps/app_seeds/test/data/variety_autoclassify_test.dart new file mode 100644 index 0000000..8ebc0a1 --- /dev/null +++ b/apps/app_seeds/test/data/variety_autoclassify_test.dart @@ -0,0 +1,107 @@ +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/species_repository.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; + +import '../support/test_support.dart'; + +/// A starter catalog, including a name ("Calabaza") shared across two species +/// so the ambiguous case can be exercised end-to-end. +const _seeds = [ + SpeciesSeed( + scientificName: 'Zea mays', + family: 'Poaceae', + commonNames: { + 'es': ['Maíz'], + 'en': ['Maize', 'Corn'], + }, + ), + SpeciesSeed( + scientificName: 'Solanum lycopersicum', + family: 'Solanaceae', + commonNames: { + 'es': ['Tomate'], + 'en': ['Tomato'], + }, + ), + SpeciesSeed( + scientificName: 'Cucurbita pepo', + family: 'Cucurbitaceae', + commonNames: { + 'es': ['Calabaza', 'Calabacín'], + }, + ), + SpeciesSeed( + scientificName: 'Cucurbita maxima', + family: 'Cucurbitaceae', + commonNames: { + 'es': ['Calabaza'], + }, + ), +]; + +void main() { + late AppDatabase db; + late VarietyRepository repo; + + setUp(() async { + db = newTestDatabase(); + repo = newTestRepository(db); + await SpeciesRepository(db, idGen: IdGen()).seedBundled(_seeds); + }); + tearDown(() => db.close()); + + Future only() async => db.select(db.varieties).getSingle(); + + test('addQuickVariety auto-links the species named in the label', () async { + await repo.addQuickVariety(label: 'Maíz de la abuela'); + final v = await only(); + final species = await (db.select( + db.species, + )..where((s) => s.id.equals(v.speciesId!))).getSingle(); + expect(species.scientificName, 'Zea mays'); + // Category is prefilled from the species family. + expect(v.category, 'Poaceae'); + }); + + test('auto-classification is accent-insensitive', () async { + await repo.addQuickVariety(label: 'Maiz dulce'); // typed without the accent + final v = await only(); + expect(v.speciesId, isNotNull); + expect(v.category, 'Poaceae'); + }); + + test('an explicit category is kept, species still links', () async { + await repo.addQuickVariety(label: 'Tomate cherry', category: 'Mi huerto'); + final v = await only(); + expect(v.speciesId, isNotNull); + expect(v.category, 'Mi huerto'); // not overwritten by the family + }); + + test('an unknown label leaves the variety unclassified', () async { + await repo.addQuickVariety(label: 'Girasol gigante'); + final v = await only(); + expect(v.speciesId, isNull); + expect(v.category, isNull); + }); + + test('an ambiguous name is left for the grower to disambiguate', () async { + await repo.addQuickVariety(label: 'Calabaza de invierno'); + final v = await only(); + expect(v.speciesId, isNull); + expect(v.category, isNull); + }); + + test('nameDraft auto-classifies when the draft has no species yet', () async { + final id = await repo.addDraftVariety(Uint8List.fromList([1, 2, 3])); + await repo.nameDraft(id, 'Tomate rosa'); + final v = await only(); + expect(v.isDraft, isFalse); + expect(v.label, 'Tomate rosa'); + expect(v.speciesId, isNotNull); + expect(v.category, 'Solanaceae'); + }); +} diff --git a/apps/app_seeds/test/domain/species_autoclassify_test.dart b/apps/app_seeds/test/domain/species_autoclassify_test.dart new file mode 100644 index 0000000..000b800 --- /dev/null +++ b/apps/app_seeds/test/domain/species_autoclassify_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/species_autoclassify.dart'; + +/// A small catalog spanning the tricky cases: accents, multi-word names, a +/// name shared across two species, and substrings that must NOT fire. +const _names = [ + SpeciesNameEntry(speciesId: 'zea', name: 'Maíz'), + SpeciesNameEntry(speciesId: 'zea', name: 'Maize'), + SpeciesNameEntry(speciesId: 'zea', name: 'Zea mays'), + SpeciesNameEntry(speciesId: 'tomato', name: 'Tomate'), + SpeciesNameEntry(speciesId: 'tomato', name: 'Tomato'), + SpeciesNameEntry(speciesId: 'bean', name: 'Common bean'), + SpeciesNameEntry(speciesId: 'faba', name: 'Broad bean'), + SpeciesNameEntry(speciesId: 'faba', name: 'Haba'), + SpeciesNameEntry(speciesId: 'pea', name: 'Pea'), + // "Calabaza" is deliberately shared → ambiguous, must stay unclassified. + SpeciesNameEntry(speciesId: 'pepo', name: 'Calabaza'), + SpeciesNameEntry(speciesId: 'maxima', name: 'Calabaza'), +]; + +void main() { + String? match(String label) => matchSpeciesInLabel(label, _names); + + test('matches a common name embedded in a free-text label', () { + expect(match('Maíz de la abuela'), 'zea'); + expect(match('Tomate cherry'), 'tomato'); + }); + + test('is accent- and case-insensitive', () { + expect(match('MAIZ dulce'), 'zea'); // no accent, upper-case + expect(match('tomate rosa'), 'tomato'); + }); + + test('matches the scientific name too', () { + expect(match('Zea mays landrace'), 'zea'); + }); + + test('matches multi-word names as a contiguous phrase', () { + expect(match('Broad bean Aquadulce'), 'faba'); + expect(match('Common bean from grandma'), 'bean'); + }); + + test('prefers the longest (most specific) name', () { + // "bean" alone is not in the catalog, but the two-word names should win + // over any single-word coincidence. + expect(match('Broad bean'), 'faba'); + }); + + test('folds a plural label word to a singular catalog name', () { + expect(match('Tomates de rama'), 'tomato'); + expect(match('Habas de mi huerto'), 'faba'); + }); + + test('does not fire on a substring that is not a whole word', () { + expect(match('Habanero picante'), isNull); // contains "haba" as substring + expect(match('Peach tree'), isNull); // contains "pea" as substring + }); + + test('returns null when a name is shared across species (ambiguous)', () { + expect(match('Calabaza de invierno'), isNull); + }); + + test('returns null when nothing matches', () { + expect(match('Girasol gigante'), isNull); + expect(match(' '), isNull); + expect(match(''), isNull); + }); +} diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index 0501b9c..b03377e 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -76,6 +76,59 @@ void main() { await disposeTree(tester); }); + testWidgets('typing a name suggests the species and links it on tap', ( + tester, + ) async { + final species = newTestSpeciesRepository(db); + await species.seedBundled(const [ + SpeciesSeed( + scientificName: 'Zea mays', + family: 'Poaceae', + commonNames: { + 'en': ['Maize'], + 'es': ['Maíz'], + }, + ), + ]); + final repo = newTestRepository(db); + // A label that names no species yet, so nothing is auto-linked on create. + final id = await repo.addQuickVariety(label: 'Grandma seed'); + + await tester.pumpWidget( + wrapDetail(repository: repo, varietyId: id, species: species), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.edit'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('editVariety.name')), + 'Maize criollo', + ); + await tester.pumpAndSettle(); + + // The suggestion surfaces from the typed name. + final suggestion = find.byKey(const Key('editVariety.speciesSuggestion')); + expect(suggestion, findsOneWidget); + expect(find.text('Maize (Zea mays)'), findsOneWidget); + + await tester.tap(suggestion); + await tester.pumpAndSettle(); + // Picking fills the species field with the catalog label. + expect(find.text('Maize (Zea mays)'), findsOneWidget); + + final save = find.byKey(const Key('editVariety.save')); + await tester.ensureVisible(save); + await tester.pumpAndSettle(); + await tester.tap(save, warnIfMissed: false); + await tester.pumpAndSettle(); + + // The linked species now shows on the detail header (driven by the stream). + expect(find.text('Zea mays'), findsOneWidget); + await disposeTree(tester); + }); + testWidgets('adding a lot shows it in the list', (tester) async { final repo = newTestRepository(db); final id = await repo.addQuickVariety(label: 'Bean');