feat(inventory): additive CSV import for bulk digitization
Import a spreadsheet list (the same 21-column schema the CSV export already produces) as new varieties/lots. Tolerant reader: columns matched by header name in any order, only variety_label required, unknown enums fall back to a safe default, rows sharing variety_id collapse into one variety with several lots. Species re-linked by scientific name. Additive by design (fresh ids + HLC), so it never overwrites existing rows; JSON stays the canonical id-reconciling import. Adds a hand-rolled RFC 4180 parser (no new dependency), an importCsv path in VarietyRepository/ExportImportService, an 'Import from CSV' tile in the backup section, and en/es strings. Note: i18n .g.dart also carries pre-existing About/intro strings from the working tree (regenerated by slang).
This commit is contained in:
parent
21072633b5
commit
89b61bc9e4
11 changed files with 1070 additions and 2 deletions
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/data/export_import/inventory_csv_codec.dart';
|
||||
import 'package:tane/data/species_repository.dart';
|
||||
import 'package:tane/db/database.dart';
|
||||
import 'package:tane/db/enums.dart';
|
||||
|
||||
import '../../support/test_support.dart';
|
||||
|
||||
void main() {
|
||||
const codec = InventoryCsvCodec();
|
||||
const tomato = SpeciesSeed(
|
||||
scientificName: 'Solanum lycopersicum',
|
||||
family: 'Solanaceae',
|
||||
);
|
||||
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() => db = newTestDatabase());
|
||||
tearDown(() => db.close());
|
||||
|
||||
test('inserts a variety with its lot, resolving species by name', () async {
|
||||
await newTestSpeciesRepository(db).seedBundled(const [tomato]);
|
||||
final repo = newTestRepository(db);
|
||||
|
||||
const csv = 'variety_label,category,species_scientific_name,'
|
||||
'cultivar_name,lot_type,harvest_year,quantity_kind,quantity_precise\r\n'
|
||||
'Grandma tomato,Solanaceae,Solanum lycopersicum,Marmande,seed,2024,cob,3\r\n';
|
||||
final summary = await repo.importCsv(codec.decode(csv));
|
||||
expect(summary.inserted, 1);
|
||||
|
||||
final variety = await db.select(db.varieties).getSingle();
|
||||
expect(variety.label, 'Grandma tomato');
|
||||
expect(variety.cultivarName, 'Marmande');
|
||||
final species = await db.select(db.species).getSingle();
|
||||
expect(variety.speciesId, species.id);
|
||||
|
||||
final lot = await db.select(db.lots).getSingle();
|
||||
expect(lot.varietyId, variety.id);
|
||||
expect(lot.type, LotType.seed);
|
||||
expect(lot.harvestYear, 2024);
|
||||
expect(lot.quantityKind, 'cob');
|
||||
expect(lot.quantityPrecise, 3.0);
|
||||
});
|
||||
|
||||
test('an unmatched species name leaves speciesId null', () async {
|
||||
final repo = newTestRepository(db); // no bundled catalog
|
||||
const csv = 'variety_label,species_scientific_name\r\n'
|
||||
'Mystery bean,Phaseolus nonexistus\r\n';
|
||||
await repo.importCsv(codec.decode(csv));
|
||||
final variety = await db.select(db.varieties).getSingle();
|
||||
expect(variety.speciesId, isNull);
|
||||
});
|
||||
|
||||
test('is additive: importing the same CSV twice adds it twice', () async {
|
||||
final repo = newTestRepository(db);
|
||||
const csv = 'variety_label\r\nTomato\r\n';
|
||||
await repo.importCsv(codec.decode(csv));
|
||||
await repo.importCsv(codec.decode(csv));
|
||||
final varieties = await db.select(db.varieties).get();
|
||||
expect(varieties, hasLength(2));
|
||||
});
|
||||
|
||||
test('stores vernacular names and links attached to the new variety', () async {
|
||||
final repo = newTestRepository(db);
|
||||
const csv = 'variety_label,vernacular_names,external_links\r\n'
|
||||
'Tomato,"tomate (es); granny tomato","Wiki|https://example.org"\r\n';
|
||||
await repo.importCsv(codec.decode(csv));
|
||||
|
||||
final variety = await db.select(db.varieties).getSingle();
|
||||
final names = await db.select(db.varietyVernacularNames).get();
|
||||
expect(names.map((n) => n.name), containsAll(['tomate', 'granny tomato']));
|
||||
expect(names.every((n) => n.varietyId == variety.id), isTrue);
|
||||
|
||||
final links = await db.select(db.externalLinks).get();
|
||||
expect(links.single.url, 'https://example.org');
|
||||
expect(links.single.parentId, variety.id);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/data/export_import/inventory_csv_codec.dart';
|
||||
import 'package:tane/data/export_import/inventory_snapshot.dart';
|
||||
import 'package:tane/db/database.dart';
|
||||
import 'package:tane/db/enums.dart';
|
||||
|
||||
void main() {
|
||||
const codec = InventoryCsvCodec();
|
||||
|
||||
group('decode (additive CSV import)', () {
|
||||
test('a minimal one-column list only needs variety_label', () {
|
||||
final csv = 'variety_label\r\nGrandma tomato\r\nPurple carrot\r\n';
|
||||
final result = codec.decode(csv);
|
||||
expect(result.skippedRows, 0);
|
||||
expect(result.varieties.map((v) => v.label), [
|
||||
'Grandma tomato',
|
||||
'Purple carrot',
|
||||
]);
|
||||
// No lot columns → no lots.
|
||||
expect(result.varieties.first.lots, isEmpty);
|
||||
});
|
||||
|
||||
test('skips rows without a variety_label', () {
|
||||
final csv = 'variety_label,category\r\n,Solanaceae\r\nTomato,Solanaceae\r\n';
|
||||
final result = codec.decode(csv);
|
||||
expect(result.skippedRows, 1);
|
||||
expect(result.varieties, hasLength(1));
|
||||
expect(result.varieties.single.label, 'Tomato');
|
||||
});
|
||||
|
||||
test('maps columns by header name regardless of order', () {
|
||||
final csv = 'category,variety_label,harvest_year,lot_type\r\n'
|
||||
'Solanaceae,Tomato,2024,seed\r\n';
|
||||
final v = codec.decode(csv).varieties.single;
|
||||
expect(v.label, 'Tomato');
|
||||
expect(v.category, 'Solanaceae');
|
||||
expect(v.lots.single.harvestYear, 2024);
|
||||
expect(v.lots.single.type, LotType.seed);
|
||||
});
|
||||
|
||||
test('ignores unknown columns', () {
|
||||
final csv = 'variety_label,made_up_column\r\nTomato,whatever\r\n';
|
||||
final v = codec.decode(csv).varieties.single;
|
||||
expect(v.label, 'Tomato');
|
||||
});
|
||||
|
||||
test('unknown enums fall back: lot_type→seed, offer_status→private, '
|
||||
'quantity_kind dropped', () {
|
||||
final csv = 'variety_label,lot_type,offer_status,quantity_kind\r\n'
|
||||
'Tomato,spaceship,gift,truckload\r\n';
|
||||
final lot = codec.decode(csv).varieties.single.lots.single;
|
||||
expect(lot.type, LotType.seed);
|
||||
expect(lot.offerStatus, OfferStatus.private);
|
||||
expect(lot.quantityKind, isNull);
|
||||
});
|
||||
|
||||
test('keeps a valid quantity_kind and parses precise/label', () {
|
||||
final csv = 'variety_label,quantity_kind,quantity_precise,quantity_label\r\n'
|
||||
'Maize,cob,3,a handful\r\n';
|
||||
final lot = codec.decode(csv).varieties.single.lots.single;
|
||||
expect(lot.quantityKind, 'cob');
|
||||
expect(lot.quantityPrecise, 3.0);
|
||||
expect(lot.quantityLabel, 'a handful');
|
||||
});
|
||||
|
||||
test('rows sharing a variety_id collapse into one variety with many lots', () {
|
||||
final csv = 'variety_id,variety_label,lot_type,harvest_year\r\n'
|
||||
'v1,Tomato,seed,2023\r\n'
|
||||
'v1,Tomato,seed,2024\r\n'
|
||||
'v2,Carrot,seed,2024\r\n';
|
||||
final result = codec.decode(csv);
|
||||
expect(result.varieties, hasLength(2));
|
||||
final tomato = result.varieties.firstWhere((v) => v.label == 'Tomato');
|
||||
expect(tomato.lots.map((l) => l.harvestYear), [2023, 2024]);
|
||||
});
|
||||
|
||||
test('rows without a variety_id each become their own variety', () {
|
||||
final csv = 'variety_label,lot_type\r\nTomato,seed\r\nTomato,plant\r\n';
|
||||
final result = codec.decode(csv);
|
||||
expect(result.varieties, hasLength(2));
|
||||
});
|
||||
|
||||
test('parses quoted fields with commas, doubled quotes and newlines', () {
|
||||
final csv = 'variety_label,variety_notes\r\n'
|
||||
'"Bean, ""the best""","line one\nline two"\r\n';
|
||||
final v = codec.decode(csv).varieties.single;
|
||||
expect(v.label, 'Bean, "the best"');
|
||||
expect(v.notes, 'line one\nline two');
|
||||
});
|
||||
|
||||
test('splits vernacular names and links back out', () {
|
||||
final csv = 'variety_label,vernacular_names,external_links\r\n'
|
||||
'Tomato,"tomate (es); granny tomato","Wiki|https://example.org"\r\n';
|
||||
final v = codec.decode(csv).varieties.single;
|
||||
expect(v.vernacularNames.map((n) => n.name), ['tomate', 'granny tomato']);
|
||||
expect(v.vernacularNames.first.language, 'es');
|
||||
expect(v.links.single.url, 'https://example.org');
|
||||
expect(v.links.single.title, 'Wiki');
|
||||
});
|
||||
|
||||
test('tolerates \\n-only line endings and a missing trailing newline', () {
|
||||
final csv = 'variety_label\nTomato\nCarrot';
|
||||
final result = codec.decode(csv);
|
||||
expect(result.varieties.map((v) => v.label), ['Tomato', 'Carrot']);
|
||||
});
|
||||
|
||||
test('an empty or header-only file yields nothing', () {
|
||||
expect(codec.decode('').varieties, isEmpty);
|
||||
expect(codec.decode('variety_label\r\n').varieties, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('round-trip with the export encoder', () {
|
||||
Variety variety() => Variety(
|
||||
id: 'var-1',
|
||||
createdAt: 0,
|
||||
updatedAt: '000000000001000:00000:node-a',
|
||||
lastAuthor: 'node-a',
|
||||
isDeleted: false,
|
||||
schemaRowVersion: 1,
|
||||
label: 'Grandma tomato',
|
||||
speciesId: 'sp-1',
|
||||
cultivarName: 'Marmande',
|
||||
category: 'Solanaceae',
|
||||
notes: 'heirloom',
|
||||
);
|
||||
|
||||
Lot lot({String id = 'lot-1'}) => Lot(
|
||||
id: id,
|
||||
createdAt: 0,
|
||||
updatedAt: '000000000002000:00000:node-a',
|
||||
lastAuthor: 'node-a',
|
||||
isDeleted: false,
|
||||
schemaRowVersion: 1,
|
||||
varietyId: 'var-1',
|
||||
type: LotType.seed,
|
||||
harvestYear: 2024,
|
||||
harvestMonth: 9,
|
||||
quantityKind: 'cob',
|
||||
quantityPrecise: 2,
|
||||
quantityLabel: null,
|
||||
presentation: null,
|
||||
storageLocation: 'fridge',
|
||||
offerStatus: OfferStatus.shared,
|
||||
seedbankId: null,
|
||||
);
|
||||
|
||||
test('encode → decode preserves variety fields and both lots', () {
|
||||
final csv = codec.encode(
|
||||
InventorySnapshot(
|
||||
varieties: [variety()],
|
||||
lots: [lot(), lot(id: 'lot-2')],
|
||||
speciesNamesById: const {'sp-1': 'Solanum lycopersicum'},
|
||||
),
|
||||
);
|
||||
final v = codec.decode(csv).varieties.single;
|
||||
expect(v.label, 'Grandma tomato');
|
||||
expect(v.category, 'Solanaceae');
|
||||
expect(v.cultivarName, 'Marmande');
|
||||
expect(v.notes, 'heirloom');
|
||||
expect(v.scientificName, 'Solanum lycopersicum');
|
||||
expect(v.lots, hasLength(2));
|
||||
final l = v.lots.first;
|
||||
expect(l.type, LotType.seed);
|
||||
expect(l.harvestYear, 2024);
|
||||
expect(l.harvestMonth, 9);
|
||||
expect(l.quantityKind, 'cob');
|
||||
expect(l.quantityPrecise, 2.0);
|
||||
expect(l.storageLocation, 'fridge');
|
||||
expect(l.offerStatus, OfferStatus.shared);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue