feat(species): expand catalog to ~1200 edible species from Wikidata

Grow the bundled species catalog from 14 hand-curated entries to ~1200
edible/cultivated species, internationalized in 13 languages (es, en, fr,
de, it, pt, ca, gl, eu, ar, zh, ja, ru — Latin + Arabic RTL + CJK + Cyrillic).

- Add a reproducible generator (tool/gen_species_catalog.dart) that queries
  Wikidata (CC0, no attribution burden) in two phases, filters out
  non-vernacular noise (author citations, ranks, initials) and applies a
  relevance floor, then merges hand-curated, authoritative core-crop data
  (tool/curated_overrides.json: names, family, viability_years). GBIF is used
  only as an identifier. The generated species.json (v3) is committed.
- Carry wikidata_qid and gbif_key through the parse/seed pipeline; the columns
  already existed, so no DB migration.
- Rewrite seedBundled to one read + one batch (was a SELECT per species on
  every startup — a real cost at ~1200 rows) and keep it idempotent with
  backfill of the new reference fields.
- Move species search filtering to SQL (LIKE) so a large catalog is not pulled
  into memory on every keystroke.
- Cover the generator transform, the generated asset, and the new fields with
  tests.
This commit is contained in:
vjrj 2026-07-10 02:00:47 +02:00
parent ba87bf2719
commit 959fa2551c
9 changed files with 68483 additions and 120 deletions

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

@ -39,12 +39,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']);
},
);
@ -80,4 +82,86 @@ 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);
});
}

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