Merge branch 'main' into spike/block2-derisking

This commit is contained in:
vjrj 2026-07-10 03:06:30 +02:00
commit 12f48ea318
24 changed files with 69558 additions and 341 deletions

View file

@ -210,6 +210,37 @@ void main() {
expect(variety.lastAuthor, 'node-other');
});
test('import overwrites a lot edited elsewhere (LWW)', () async {
final ids = await populate();
// Fork: a second device imports this inventory, edits the lot
final json = codec.encode(await source.exportInventory());
final otherDb = newTestDatabase();
addTearDown(otherDb.close);
final other = newTestRepository(otherDb, nodeId: 'node-other');
await other.importInventory(codec.decode(json));
await other.updateLot(
lotId: ids.lotId,
type: LotType.seed,
harvestYear: 2025,
quantity: const Quantity(kind: QuantityKind.cob, count: 9),
storageLocation: 'cellar',
);
// and its export comes back: the newer edit wins here.
final back = codec.encode(await other.exportInventory());
final summary = await source.importInventory(codec.decode(back));
expect(summary.updated, greaterThan(0));
final lot = await (sourceDb.select(
sourceDb.lots,
)..where((l) => l.id.equals(ids.lotId))).getSingle();
expect(lot.harvestYear, 2025);
expect(lot.quantityPrecise, 9);
expect(lot.storageLocation, 'cellar');
expect(lot.lastAuthor, 'node-other');
});
test('after an import the local clock never stamps behind it', () async {
await populate();
final json = codec.encode(await source.exportInventory());
@ -236,6 +267,54 @@ void main() {
expect(Hlc.parse(written.updatedAt).compareTo(maxImported), greaterThan(0));
});
test('a restarted repository re-seeds its clock from stored rows so local '
'edits still win LWW (survives a lagging wall clock)', () async {
// A peer device whose wall clock runs far ahead writes the inventory.
final peerDb = newTestDatabase();
addTearDown(peerDb.close);
final peer = VarietyRepository(
peerDb,
idGen: IdGen(),
nodeId: 'peer',
nowMillis: () => 9000000000000, // ~year 2255
);
final varietyId = await peer.addQuickVariety(label: 'Maize');
final lotId = await peer.addLot(varietyId: varietyId, harvestYear: 2024);
final json = codec.encode(await peer.exportInventory());
// Our device (wall clock in the real, much earlier present) imports it.
final myDb = newTestDatabase();
addTearDown(myDb.close);
const myWall = 1000; // far behind the imported stamps
final importing = VarietyRepository(
myDb,
idGen: IdGen(),
nodeId: 'mine',
nowMillis: () => myWall,
);
await importing.importInventory(codec.decode(json));
// App restart: a brand-new repository instance over the same DB, whose
// in-memory clock starts fresh at Hlc.zero and whose wall clock lags.
final restarted = VarietyRepository(
myDb,
idGen: IdGen(),
nodeId: 'mine',
nowMillis: () => myWall,
);
final before = await _lotStamp(myDb, lotId);
await restarted.updateLot(
lotId: lotId,
type: LotType.seed,
harvestYear: 2030,
);
final after = await _lotStamp(myDb, lotId);
// The edit must out-stamp the imported row, or it would silently lose
// last-writer-wins when this device shares its backup back.
expect(Hlc.parse(after).compareTo(Hlc.parse(before)), greaterThan(0));
});
test('unmatched species leaves speciesId null instead of dangling', () async {
await populate();
final json = codec.encode(await source.exportInventory());
@ -250,3 +329,11 @@ void main() {
expect(variety.speciesId, isNull);
});
}
/// The packed HLC `updatedAt` currently stored for [lotId].
Future<String> _lotStamp(AppDatabase db, String lotId) async {
final lot = await (db.select(
db.lots,
)..where((l) => l.id.equals(lotId))).getSingle();
return lot.updatedAt;
}

View file

@ -0,0 +1,84 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/species_catalog.dart';
/// Guards the committed, generated catalog asset (produced by
/// `tool/gen_species_catalog.dart`). Reads the file directly no Flutter asset
/// bundle needed so it runs as a plain VM test.
void main() {
// `flutter test` runs with the package root as the working directory.
final file = File('assets/catalog/species.json');
final json = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
final species = (json['species'] as List).cast<Map<String, dynamic>>();
test('is the current (version 3) generated catalog', () {
expect(json['version'], 3);
});
test('is a broad catalog, not the old starter set', () {
// The expanded catalog holds ~1200 species (was 14 by hand). The floor
// leaves headroom for Wikidata drift across regenerations.
expect(species.length, greaterThan(900));
});
test('every entry has a scientific name and at least one localized name', () {
for (final e in species) {
final sci = e['scientific_name'];
expect(sci, isA<String>());
expect((sci as String).trim(), isNotEmpty);
final common = e['common'] as Map<String, dynamic>;
expect(common, isNotEmpty, reason: '$sci has no common names');
for (final entry in common.entries) {
final names = (entry.value as List).cast<String>();
expect(names, isNotEmpty);
expect(names.every((n) => n.trim().isNotEmpty), isTrue);
}
}
});
test('identifiers and reference fields are well typed', () {
for (final e in species) {
expect(e['wikidata_qid'], matches(r'^Q\d+$'));
if (e.containsKey('gbif_key')) expect(e['gbif_key'], isA<int>());
if (e.containsKey('viability_years')) {
expect(e['viability_years'], isA<int>());
}
}
});
test('scientific names are unique', () {
final names = species.map((e) => e['scientific_name'] as String).toList();
expect(names.length, names.toSet().length);
});
test('is genuinely multilingual (non-Latin scripts present)', () {
int withLang(String code) =>
species.where((e) => (e['common'] as Map).containsKey(code)).length;
// International by design: Arabic (RTL) and CJK must be represented, not
// an es/en-only catalog.
expect(withLang('ar'), greaterThan(0), reason: 'no Arabic names');
expect(withLang('zh'), greaterThan(0), reason: 'no Chinese names');
expect(withLang('ja'), greaterThan(0), reason: 'no Japanese names');
});
test('keeps the curated core crops with their authoritative names', () {
final byName = {for (final e in species) e['scientific_name'] as String: e};
final tomato = byName['Solanum lycopersicum'];
final carrot = byName['Daucus carota'];
expect(tomato, isNotNull);
expect(carrot, isNotNull);
// Curated names lead (not Wikidata's messier taxon labels) and viability
// reference data survives the regeneration.
expect((tomato!['common'] as Map)['es'], contains('Tomate'));
expect((carrot!['common'] as Map)['es'], contains('Zanahoria'));
expect(carrot['viability_years'], 3);
});
test('parses through the app loader without error', () {
final seeds = parseSpeciesCatalog(file.readAsStringSync());
expect(seeds.length, species.length);
});
}

View file

@ -40,12 +40,14 @@ void main() {
'parseSpeciesCatalog reads scientific name, family and common names',
() {
const json = '''
{"version":1,"species":[
{"scientific_name":"Zea mays","family":"Poaceae","common":{"en":["Maize"],"es":["Maíz"]}}
{"version":3,"species":[
{"scientific_name":"Zea mays","family":"Poaceae","wikidata_qid":"Q11575","gbif_key":2703077,"common":{"en":["Maize"],"es":["Maíz"]}}
]}''';
final seeds = parseSpeciesCatalog(json);
expect(seeds.single.scientificName, 'Zea mays');
expect(seeds.single.family, 'Poaceae');
expect(seeds.single.wikidataQid, 'Q11575');
expect(seeds.single.gbifKey, 2703077);
expect(seeds.single.commonNames['es'], ['Maíz']);
},
);
@ -140,4 +142,99 @@ void main() {
await repo.seedBundled(_seeds);
expect(await repo.search(' '), isEmpty);
});
test('seedBundled stores wikidata id and gbif key', () async {
await repo.seedBundled(const [
SpeciesSeed(
scientificName: 'Solanum lycopersicum',
family: 'Solanaceae',
wikidataQid: 'Q13223',
gbifKey: 2930137,
commonNames: {
'en': ['Tomato'],
},
),
]);
final row = await db.select(db.species).getSingle();
expect(row.wikidataQid, 'Q13223');
expect(row.gbifKey, 2930137);
});
test('seedBundled backfills reference fields added later', () async {
// First seed lacks the enrichment fields (an older catalog).
await repo.seedBundled(const [
SpeciesSeed(
scientificName: 'Allium cepa',
commonNames: {
'en': ['Onion'],
},
),
]);
// A later catalog carries them; identity is preserved, fields are filled.
await repo.seedBundled(const [
SpeciesSeed(
scientificName: 'Allium cepa',
wikidataQid: 'Q23485',
gbifKey: 2857697,
viabilityYears: 1,
commonNames: {
'en': ['Onion'],
},
),
]);
final rows = await db.select(db.species).get();
expect(rows.length, 1);
expect(rows.single.wikidataQid, 'Q23485');
expect(rows.single.gbifKey, 2857697);
expect(rows.single.viabilityYears, 1);
});
test('search matches a non-Latin common name', () async {
await repo.seedBundled(const [
SpeciesSeed(
scientificName: 'Solanum lycopersicum',
commonNames: {
'ar': ['طماطم'],
'zh': ['番茄'],
},
),
]);
final ar = await repo.search('طماطم', languageCode: 'ar');
expect(ar.single.scientificName, 'Solanum lycopersicum');
expect(ar.single.commonName, 'طماطم');
final zh = await repo.search('番茄', languageCode: 'zh');
expect(zh.single.commonName, '番茄');
});
test('search respects the limit', () async {
final many = [
for (var i = 0; i < 20; i++)
SpeciesSeed(
scientificName: 'Genus species$i',
commonNames: const {
'en': ['Plant'],
},
),
];
await repo.seedBundled(many);
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

@ -0,0 +1,159 @@
import 'package:flutter_test/flutter_test.dart';
import '../../tool/gen_species_catalog.dart';
void main() {
group('isVernacularName', () {
test('accepts genuine common names', () {
expect(isVernacularName('Tomate', 'solanum lycopersicum'), isTrue);
expect(isVernacularName("Queen Anne's lace", 'daucus carota'), isTrue);
expect(isVernacularName('番茄', 'solanum lycopersicum'), isTrue);
});
test('rejects the scientific name itself', () {
expect(
isVernacularName('Solanum lycopersicum', 'solanum lycopersicum'),
isFalse,
);
});
test('rejects scientific noise: commas, parentheses, ranks, initials', () {
expect(isVernacularName('Remusatia formosana , Hayata', 'x'), isFalse);
expect(isVernacularName('滇常山(原变种)', 'x'), isFalse);
expect(isVernacularName('Brassica oleracea var. acephala', 'x'), isFalse);
expect(isVernacularName('Z. mays', 'x'), isFalse);
expect(isVernacularName('Allium cepa L.', 'x'), isFalse);
expect(isVernacularName('Prunus × domestica', 'x'), isFalse);
});
});
group('buildCatalog', () {
test('emits version 3 and sorts by scientific name', () {
final catalog = buildCatalog([
SpeciesRecord(
qid: 'Q2',
scientificName: 'Zea mays',
common: {
'en': {'Maize'},
},
),
SpeciesRecord(
qid: 'Q1',
scientificName: 'Allium cepa',
common: {
'en': {'Onion'},
},
),
], const {});
expect(catalog['version'], 3);
expect((catalog['species'] as List).map((e) => e['scientific_name']), [
'Allium cepa',
'Zea mays',
]);
});
test('dedups by scientific name, merging names and backfilling ids', () {
final catalog = buildCatalog([
SpeciesRecord(
qid: 'Q13223',
scientificName: 'Solanum lycopersicum',
family: 'Solanaceae',
common: {
'es': {'tomate'},
},
),
SpeciesRecord(
qid: 'Q-dupe',
scientificName: 'Solanum lycopersicum',
gbifKey: 2930137,
common: {
'en': {'tomato'},
'es': {'tomatera'},
},
),
], const {});
final entry = (catalog['species'] as List).single as Map;
expect(entry['wikidata_qid'], 'Q13223'); // first entity kept
expect(entry['family'], 'Solanaceae');
expect(entry['gbif_key'], 2930137); // backfilled from the duplicate
expect(entry['common']['es'], containsAll(['tomate', 'tomatera']));
expect(entry['common']['en'], ['tomato']);
});
test('drops names that are not real vernaculars', () {
final catalog = buildCatalog([
SpeciesRecord(
qid: 'Q1',
scientificName: 'Solanum lycopersicum',
common: {
'es': {'Solanum lycopersicum', 'Tomate'},
},
),
], const {});
expect((catalog['species'] as List).single['common']['es'], ['Tomate']);
});
test('minLanguages is a relevance floor for Wikidata-only species', () {
final records = [
SpeciesRecord(
qid: 'Q1',
scientificName: 'Obscura ignota',
common: {
'zh': {'仅中文'},
},
),
SpeciesRecord(
qid: 'Q2',
scientificName: 'Nota bene',
common: {
'en': {'well known'},
'es': {'bien conocida'},
},
),
];
final kept =
(buildCatalog(records, const {}, minLanguages: 2)['species'] as List)
.map((e) => e['scientific_name'])
.toList();
expect(kept, ['Nota bene']); // the 1-language species is filtered out
});
test('curated overrides win, come first, and bypass the floor', () {
final catalog = buildCatalog(
[
SpeciesRecord(
qid: 'Q081',
scientificName: 'Daucus carota',
family: 'Apiaceae',
common: {
// Wikidata's messy taxon labels for the wild carrot.
'en': {'Devils Plague', 'wild carrot'},
},
),
],
const {
'Daucus carota': CuratedOverride(
family: 'Apiaceae',
viabilityYears: 3,
common: {
'es': ['Zanahoria'],
'en': ['Carrot'],
},
),
},
// Would be filtered as it has only one Wikidata language, but curated
// species are always kept.
minLanguages: 4,
);
final entry = (catalog['species'] as List).single as Map;
expect(entry['viability_years'], 3);
expect(entry['common']['es'], ['Zanahoria']);
// Curated name first, then the Wikidata vernaculars.
expect(entry['common']['en'].first, 'Carrot');
expect(entry['common']['en'], contains('wild carrot'));
});
});
}

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