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.
This commit is contained in:
vjrj 2026-07-10 02:05:00 +02:00
parent 0e6ef13d00
commit b840b83c42
15 changed files with 510 additions and 3 deletions

View file

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

View file

@ -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<Variety> 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');
});
}

View file

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

View file

@ -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');