feat(inventory): CSV/JSON export and JSON import with LWW reconciliation

Interchange export/import for Phase 1 (data-model §7):

- JSON: canonical, versioned envelope (formatVersion 1) with all sync
  metadata verbatim, photos embedded as base64, tombstones excluded.
  Species are re-resolved on import by scientific name (catalog ids are
  per-install). Reader tolerates unknown fields/enum values (§5.2) and
  rejects newer format versions with a clear error.
- CSV: export-only spreadsheet flatten, one row per lot, RFC 4180
  escaping, never any photo bytes.
- Import merges by UUIDv7 id in one transaction: insert-if-unknown
  preserving original stamps, last-writer-wins by packed HLC for known
  mutable rows, append-only movements; afterwards the local clock
  receiveEvent()s the newest imported stamp so it never runs behind.
- Settings gains a Backup section (export CSV/JSON, import JSON with
  confirmation); file dialogs behind a FileService interface backed by
  file_picker (MIT).
- Tests: codec round-trip and tolerance, reconciler LWW, repository
  export→import round-trip (fresh DB, idempotent re-import, newer-local
  wins, clock monotonicity, species re-resolution), backup widget flows.
This commit is contained in:
vjrj 2026-07-09 12:46:53 +02:00
parent 136ed701a7
commit 2812c99280
25 changed files with 2207 additions and 7 deletions

View file

@ -0,0 +1,98 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/import_reconciler.dart';
void main() {
const reconciler = ImportReconciler();
String stamp(int millis, {int counter = 0, String node = 'node-a'}) =>
Hlc(millis: millis, counter: counter, nodeId: node).pack();
group('reconcileMutable (LWW by packed HLC)', () {
test('unknown id inserts', () {
expect(
reconciler.reconcileMutable(
existingUpdatedAt: null,
incomingUpdatedAt: stamp(1000),
),
ReconcileAction.insert,
);
});
test('newer incoming stamp updates', () {
expect(
reconciler.reconcileMutable(
existingUpdatedAt: stamp(1000),
incomingUpdatedAt: stamp(2000),
),
ReconcileAction.update,
);
});
test('older incoming stamp skips', () {
expect(
reconciler.reconcileMutable(
existingUpdatedAt: stamp(2000),
incomingUpdatedAt: stamp(1000),
),
ReconcileAction.skip,
);
});
test('identical stamp skips (re-import is a no-op)', () {
expect(
reconciler.reconcileMutable(
existingUpdatedAt: stamp(1000),
incomingUpdatedAt: stamp(1000),
),
ReconcileAction.skip,
);
});
test('same millis resolves by counter then node id', () {
expect(
reconciler.reconcileMutable(
existingUpdatedAt: stamp(1000, counter: 1),
incomingUpdatedAt: stamp(1000, counter: 2),
),
ReconcileAction.update,
);
expect(
reconciler.reconcileMutable(
existingUpdatedAt: stamp(1000, node: 'node-a'),
incomingUpdatedAt: stamp(1000, node: 'node-b'),
),
ReconcileAction.update,
);
});
});
group('reconcileAppendOnly', () {
test('unknown id inserts, known id skips (never updates)', () {
expect(
reconciler.reconcileAppendOnly(exists: false),
ReconcileAction.insert,
);
expect(
reconciler.reconcileAppendOnly(exists: true),
ReconcileAction.skip,
);
});
});
group('maxHlc', () {
test('returns the newest stamp', () {
final max = reconciler.maxHlc([
stamp(1000),
stamp(3000, counter: 2),
stamp(3000, counter: 1),
stamp(2000),
]);
expect(max, Hlc(millis: 3000, counter: 2, nodeId: 'node-a'));
});
test('returns null when empty', () {
expect(reconciler.maxHlc(const []), isNull);
});
});
}

View file

@ -0,0 +1,197 @@
import 'dart:typed_data';
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();
Variety variety({
String id = 'var-1',
String label = 'Grandma tomato',
String? notes,
}) => Variety(
id: id,
createdAt: 0,
updatedAt: '000000000001000:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
label: label,
speciesId: 'sp-1',
cultivarName: null,
category: 'Solanaceae',
notes: notes,
);
Lot lot({String id = 'lot-1', String varietyId = 'var-1'}) => Lot(
id: id,
createdAt: 0,
updatedAt: '000000000002000:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
varietyId: varietyId,
type: LotType.seed,
harvestYear: 2024,
harvestMonth: null,
quantityKind: 'cob',
quantityPrecise: 2,
quantityLabel: null,
presentation: null,
storageLocation: null,
offerStatus: OfferStatus.private,
seedbankId: null,
);
List<String> rowsOf(String csv) =>
csv.trimRight().split('\r\n');
test('emits a header and one row per lot', () {
final csv = codec.encode(
InventorySnapshot(
varieties: [variety()],
lots: [lot(), lot(id: 'lot-2')],
speciesNamesById: const {'sp-1': 'Solanum lycopersicum'},
),
);
final rows = rowsOf(csv);
expect(rows, hasLength(3));
expect(rows.first, startsWith('variety_id,variety_label,category'));
expect(rows[1], contains('lot-1'));
expect(rows[1], contains('Solanum lycopersicum'));
expect(rows[1], contains('cob'));
expect(rows[2], contains('lot-2'));
});
test('a variety without lots still gets one row', () {
final csv = codec.encode(InventorySnapshot(varieties: [variety()]));
final rows = rowsOf(csv);
expect(rows, hasLength(2));
expect(rows[1], startsWith('var-1,Grandma tomato,Solanaceae'));
// Same cell count as the header even with empty lot columns.
expect(rows[1].split(',').length, rows.first.split(',').length);
});
test('escapes commas, quotes and newlines per RFC 4180', () {
final csv = codec.encode(
InventorySnapshot(
varieties: [
variety(label: 'Bean, "the best"', notes: 'line one\nline two'),
],
),
);
expect(csv, contains('"Bean, ""the best"""'));
expect(csv, contains('"line one\nline two"'));
});
test('joins vernacular names and links; never includes photo bytes', () {
final snapshot = InventorySnapshot(
varieties: [variety()],
vernacularNames: const [
VarietyVernacularName(
id: 'n1',
createdAt: 0,
updatedAt: '000000000001000:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
varietyId: 'var-1',
name: 'tomate de la abuela',
language: 'es',
region: null,
),
VarietyVernacularName(
id: 'n2',
createdAt: 0,
updatedAt: '000000000001000:00001:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
varietyId: 'var-1',
name: 'granny tomato',
language: null,
region: null,
),
],
externalLinks: const [
ExternalLink(
id: 'l1',
createdAt: 0,
updatedAt: '000000000001000:00002:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
parentType: ParentType.variety,
parentId: 'var-1',
url: 'https://example.org',
title: 'Wiki',
),
],
attachments: [
Attachment(
id: 'a1',
createdAt: 0,
updatedAt: '000000000001000:00003:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
parentType: ParentType.variety,
parentId: 'var-1',
kind: AttachmentKind.photo,
uri: null,
bytes: Uint8List.fromList([9, 9, 9]),
mimeType: 'image/jpeg',
sortOrder: 0,
),
],
);
final csv = codec.encode(snapshot);
expect(csv, contains('tomate de la abuela (es); granny tomato'));
expect(csv, contains('Wiki|https://example.org'));
expect(csv, isNot(contains('9,9,9')));
expect(csv.toLowerCase(), isNot(contains('base64')));
});
test('reports the latest germination rate per lot', () {
final csv = codec.encode(
InventorySnapshot(
varieties: [variety()],
lots: [lot()],
germinationTests: const [
GerminationTest(
id: 'g-old',
createdAt: 0,
updatedAt: '000000000001000:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
lotId: 'lot-1',
testedOn: 100,
sampleSize: 10,
germinatedCount: 2,
notes: null,
),
GerminationTest(
id: 'g-new',
createdAt: 0,
updatedAt: '000000000001000:00001:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
lotId: 'lot-1',
testedOn: 200,
sampleSize: 10,
germinatedCount: 8,
notes: null,
),
],
),
);
expect(csv, contains('0.80'));
expect(csv, isNot(contains('0.20')));
});
}

View file

@ -0,0 +1,205 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_json_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 = InventoryJsonCodec();
const variety = Variety(
id: 'var-1',
createdAt: 1000,
updatedAt: '000000000001000:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
label: 'Grandma tomato',
speciesId: 'sp-1',
cultivarName: 'Marmande',
category: 'Solanaceae',
notes: 'Keeps well',
);
const lot = Lot(
id: 'lot-1',
createdAt: 1001,
updatedAt: '000000000001001:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
varietyId: 'var-1',
type: LotType.seed,
harvestYear: 2024,
harvestMonth: 6,
quantityKind: 'cob',
quantityPrecise: 2,
quantityLabel: null,
presentation: null,
storageLocation: 'fridge',
offerStatus: OfferStatus.private,
seedbankId: null,
);
const name = VarietyVernacularName(
id: 'name-1',
createdAt: 1002,
updatedAt: '000000000001002:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
varietyId: 'var-1',
name: 'tomate de la abuela',
language: 'es',
region: 'Madrid',
);
const link = ExternalLink(
id: 'link-1',
createdAt: 1003,
updatedAt: '000000000001003:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
parentType: ParentType.variety,
parentId: 'var-1',
url: 'https://example.org/tomato',
title: 'Wiki',
);
const germination = GerminationTest(
id: 'germ-1',
createdAt: 1004,
updatedAt: '000000000001004:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
lotId: 'lot-1',
testedOn: 1700000000000,
sampleSize: 10,
germinatedCount: 8,
notes: null,
);
const movement = Movement(
id: 'mov-1',
createdAt: 1005,
lastAuthor: 'node-a',
schemaRowVersion: 1,
lotId: 'lot-1',
type: MovementType.received,
occurredOn: 1690000000000,
counterpartyId: 'party-1',
quantityKind: 'handful',
quantityPrecise: null,
quantityLabel: 'a handful',
parentMovementId: null,
plantareId: null,
notes: 'from the fair',
);
const party = Party(
id: 'party-1',
createdAt: 1006,
updatedAt: '000000000001006:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
displayName: 'Village bank',
publicKey: null,
kind: PartyKind.collective,
note: null,
);
final photo = Attachment(
id: 'att-1',
createdAt: 1007,
updatedAt: '000000000001007:00000:node-a',
lastAuthor: 'node-a',
isDeleted: false,
schemaRowVersion: 1,
parentType: ParentType.variety,
parentId: 'var-1',
kind: AttachmentKind.photo,
uri: null,
bytes: Uint8List.fromList([1, 2, 3, 255]),
mimeType: 'image/jpeg',
sortOrder: -1,
);
final snapshot = InventorySnapshot(
varieties: const [variety],
lots: const [lot],
vernacularNames: const [name],
externalLinks: const [link],
germinationTests: const [germination],
movements: const [movement],
parties: const [party],
attachments: [photo],
speciesNamesById: const {'sp-1': 'Solanum lycopersicum'},
);
test('encode → decode round-trips every table and field', () {
final decoded = codec.decode(codec.encode(snapshot));
expect(decoded.varieties, [variety]);
expect(decoded.lots, [lot]);
expect(decoded.vernacularNames, [name]);
expect(decoded.externalLinks, [link]);
expect(decoded.germinationTests, [germination]);
expect(decoded.movements, [movement]);
expect(decoded.parties, [party]);
expect(decoded.attachments, [photo]);
expect(decoded.speciesNamesById, {'sp-1': 'Solanum lycopersicum'});
});
test('photo bytes travel as base64', () {
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
final attachment =
(root['attachments'] as List).single as Map<String, dynamic>;
expect(attachment['bytesBase64'], base64Encode([1, 2, 3, 255]));
expect(attachment.containsKey('bytes'), isFalse);
});
test('envelope carries format/schema versions and app id', () {
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
expect(root['formatVersion'], inventoryFormatVersion);
expect(root['schemaVersion'], AppDatabase.currentSchemaVersion);
expect(root['appId'], inventoryAppId);
});
test('unknown fields are ignored (forward compatibility, §5.2)', () {
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
root['someFutureSection'] = {'x': 1};
((root['varieties'] as List).single as Map<String, dynamic>)['futureField'] =
'ignored';
final decoded = codec.decode(jsonEncode(root));
expect(decoded.varieties, [variety]);
});
test('unknown enum values fall back or drop the row (§5.2)', () {
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
((root['lots'] as List).single as Map<String, dynamic>)['type'] =
'hologram';
((root['movements'] as List).single as Map<String, dynamic>)['type'] =
'teleported';
final decoded = codec.decode(jsonEncode(root));
expect(decoded.lots.single.type, LotType.seed); // safe default
expect(decoded.movements, isEmpty); // no safe default dropped
});
test('a broken row is skipped without aborting the import', () {
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
(root['varieties'] as List).add({'id': 'var-2'}); // missing fields
final decoded = codec.decode(jsonEncode(root));
expect(decoded.varieties, [variety]);
});
test('a newer formatVersion is rejected with a clear error', () {
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
root['formatVersion'] = inventoryFormatVersion + 1;
expect(() => codec.decode(jsonEncode(root)), throwsFormatException);
});
test('malformed input is rejected', () {
expect(() => codec.decode('not json'), throwsFormatException);
expect(() => codec.decode('[1,2]'), throwsFormatException);
expect(() => codec.decode('{"foo": 1}'), throwsFormatException);
});
}

View file

@ -0,0 +1,231 @@
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_json_codec.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';
void main() {
const codec = InventoryJsonCodec();
const tomato = SpeciesSeed(
scientificName: 'Solanum lycopersicum',
family: 'Solanaceae',
);
late AppDatabase sourceDb;
late VarietyRepository source;
setUp(() {
sourceDb = newTestDatabase();
source = newTestRepository(sourceDb, nodeId: 'node-source');
});
tearDown(() => sourceDb.close());
/// Populates the source DB with one of everything and returns the ids.
Future<({String varietyId, String lotId})> populate() async {
await newTestSpeciesRepository(sourceDb).seedBundled(const [tomato]);
final varietyId = await source.addQuickVariety(
label: 'Grandma tomato',
category: 'Solanaceae',
photoBytes: Uint8List.fromList([10, 20, 30]),
);
final species = await sourceDb.select(sourceDb.species).getSingle();
await source.linkSpecies(varietyId, species.id);
await source.updateVariety(
id: varietyId,
label: 'Grandma tomato',
category: 'Solanaceae',
notes: 'Sow in March',
);
final lotId = await source.addLot(
varietyId: varietyId,
harvestYear: 2024,
harvestMonth: 6,
quantity: const Quantity(kind: QuantityKind.cob, count: 2),
storageLocation: 'fridge',
);
await source.addVernacularName(
varietyId,
'tomate de la abuela',
language: 'es',
);
await source.addExternalLink(
varietyId,
'https://example.org/tomato',
title: 'Wiki',
);
await source.addGerminationTest(
lotId: lotId,
testedOn: 1700000000000,
sampleSize: 10,
germinatedCount: 8,
);
// A second photo, then promote it to cover so sortOrder must survive.
final cover = await source.addPhoto(
varietyId,
Uint8List.fromList([40, 50, 60]),
);
await source.setPreferredPhoto(varietyId, cover);
return (varietyId: varietyId, lotId: lotId);
}
test(
'export → import into a fresh database restores the identical state',
() async {
await populate();
final json = codec.encode(await source.exportInventory());
final targetDb = newTestDatabase();
addTearDown(targetDb.close);
// The target install has its own bundled catalog (different species id).
await newTestSpeciesRepository(targetDb).seedBundled(const [tomato]);
final target = newTestRepository(targetDb, nodeId: 'node-target');
final summary = await target.importInventory(codec.decode(json));
expect(summary.inserted, greaterThan(0));
expect(summary.updated, 0);
// Everything round-trips identically, except speciesId which is
// re-resolved against the target's own catalog by scientific name.
final sourceRows = await source.exportInventory();
final targetRows = await target.exportInventory();
expect(
targetRows.varieties.map((v) => v.copyWith(speciesId: const Value(null))),
sourceRows.varieties.map((v) => v.copyWith(speciesId: const Value(null))),
);
expect(targetRows.lots, sourceRows.lots);
expect(targetRows.vernacularNames, sourceRows.vernacularNames);
expect(targetRows.externalLinks, sourceRows.externalLinks);
expect(targetRows.germinationTests, sourceRows.germinationTests);
expect(targetRows.movements, sourceRows.movements);
expect(targetRows.attachments, sourceRows.attachments);
// The species link points at the target's own catalog row.
final targetSpecies = await targetDb.select(targetDb.species).getSingle();
expect(targetRows.varieties.single.speciesId, targetSpecies.id);
// Photo bytes and cover order survived.
final detail = await target.watchVariety(
targetRows.varieties.single.id,
).first;
expect(detail!.photos.first.bytes, [40, 50, 60]);
expect(detail.photos.last.bytes, [10, 20, 30]);
},
);
test('re-importing the same file is a no-op (idempotent)', () async {
await populate();
final json = codec.encode(await source.exportInventory());
final snapshot = codec.decode(json);
final targetDb = newTestDatabase();
addTearDown(targetDb.close);
final target = newTestRepository(targetDb, nodeId: 'node-target');
await target.importInventory(snapshot);
final again = await target.importInventory(codec.decode(json));
expect(again.inserted, 0);
expect(again.updated, 0);
expect(again.skipped, greaterThan(0));
final varieties = await targetDb.select(targetDb.varieties).get();
expect(varieties, hasLength(1)); // no duplicates
});
test('import does not overwrite local edits that are newer', () async {
final ids = await populate();
final json = codec.encode(await source.exportInventory());
// The local edit happens after the export was taken.
await source.updateVariety(
id: ids.varietyId,
label: 'Renamed after export',
category: 'Solanaceae',
notes: 'Sow in March',
);
final summary = await source.importInventory(codec.decode(json));
expect(summary.updated, 0);
final variety = await (sourceDb.select(
sourceDb.varieties,
)..where((v) => v.id.equals(ids.varietyId))).getSingle();
expect(variety.label, 'Renamed after export');
});
test('import overwrites when the incoming row is newer (LWW)', () async {
final ids = await populate();
// Fork: a second device imports this inventory, edits the variety
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.updateVariety(
id: ids.varietyId,
label: 'Edited elsewhere',
category: 'Solanaceae',
notes: 'Sow in March',
);
// 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 variety = await (sourceDb.select(
sourceDb.varieties,
)..where((v) => v.id.equals(ids.varietyId))).getSingle();
expect(variety.label, 'Edited elsewhere');
expect(variety.lastAuthor, 'node-other');
});
test('after an import the local clock never stamps behind it', () async {
await populate();
final json = codec.encode(await source.exportInventory());
final snapshot = codec.decode(json);
// A target whose wall clock is frozen in the past.
final targetDb = newTestDatabase();
addTearDown(targetDb.close);
final target = VarietyRepository(
targetDb,
idGen: IdGen(),
nodeId: 'node-past',
nowMillis: () => 1000,
);
await target.importInventory(snapshot);
await target.addQuickVariety(label: 'Written after import');
final maxImported = snapshot.varieties
.map((v) => Hlc.parse(v.updatedAt))
.reduce((a, b) => a.compareTo(b) > 0 ? a : b);
final written = await (targetDb.select(
targetDb.varieties,
)..where((v) => v.label.equals('Written after import'))).getSingle();
expect(
Hlc.parse(written.updatedAt).compareTo(maxImported),
greaterThan(0),
);
});
test('unmatched species leaves speciesId null instead of dangling', () async {
await populate();
final json = codec.encode(await source.exportInventory());
final targetDb = newTestDatabase();
addTearDown(targetDb.close);
// No bundled catalog in the target: the name cannot be resolved.
final target = newTestRepository(targetDb, nodeId: 'node-target');
await target.importInventory(codec.decode(json));
final variety = await targetDb.select(targetDb.varieties).getSingle();
expect(variety.speciesId, isNull);
});
}

View file

@ -0,0 +1,150 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_json_codec.dart';
import 'package:tane/data/export_import/inventory_snapshot.dart';
import 'package:tane/db/database.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/export_import_service.dart';
import 'package:tane/services/file_service.dart';
import 'package:tane/ui/backup_section.dart';
import 'package:tane/ui/settings_screen.dart';
import '../support/test_support.dart';
/// Records calls instead of opening real dialogs.
class FakeFileService implements FileService {
String? savedName;
Uint8List? savedBytes;
Uint8List? bytesToPick;
@override
Future<String?> saveFile({
required String suggestedName,
required Uint8List bytes,
}) async {
savedName = suggestedName;
savedBytes = bytes;
return '/picked/$suggestedName';
}
@override
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
bytesToPick;
}
void main() {
late AppDatabase db;
late FakeFileService files;
late ExportImportService service;
setUp(() {
db = newTestDatabase();
files = FakeFileService();
service = ExportImportService(
repository: newTestRepository(db),
files: files,
);
});
tearDown(() => db.close());
Widget wrap(Widget child) {
LocaleSettings.setLocaleSync(AppLocale.en);
return TranslationProvider(
child: MaterialApp(
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: child,
),
);
}
testWidgets('settings shows the backup section with its three actions', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(800, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(wrap(SettingsScreen(exportImport: service)));
expect(find.text('Backup'), findsOneWidget);
expect(find.text('Export as CSV'), findsOneWidget);
expect(find.text('Export as JSON'), findsOneWidget);
expect(find.text('Import from JSON'), findsOneWidget);
});
testWidgets('export CSV saves a .csv file and confirms', (tester) async {
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.tap(find.text('Export as CSV'));
await tester.pumpAndSettle();
expect(files.savedName, endsWith('.csv'));
expect(utf8.decode(files.savedBytes!), startsWith('variety_id,'));
expect(find.text('Copy saved'), findsOneWidget);
});
testWidgets('export JSON saves a versioned .json file', (tester) async {
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.tap(find.text('Export as JSON'));
await tester.pumpAndSettle();
expect(files.savedName, endsWith('.json'));
final root =
jsonDecode(utf8.decode(files.savedBytes!)) as Map<String, dynamic>;
expect(root['formatVersion'], inventoryFormatVersion);
expect(find.text('Copy saved'), findsOneWidget);
});
testWidgets('import asks for confirmation, imports and reports counts', (
tester,
) async {
// A valid one-variety export produced by another repository.
final otherDb = newTestDatabase();
addTearDown(otherDb.close);
final other = newTestRepository(otherDb, nodeId: 'node-other');
await other.addQuickVariety(label: 'Imported bean');
files.bytesToPick = utf8.encode(
const InventoryJsonCodec().encode(await other.exportInventory()),
);
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.tap(find.text('Import from JSON'));
await tester.pumpAndSettle();
expect(find.text('Import a saved copy?'), findsOneWidget);
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget);
final varieties = await db.select(db.varieties).get();
expect(varieties.single.label, 'Imported bean');
});
testWidgets('an unreadable file reports a friendly error', (tester) async {
files.bytesToPick = utf8.encode('this is not json');
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.tap(find.text('Import from JSON'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
expect(
find.text('This file could not be read as a Tanemaki copy'),
findsOneWidget,
);
});
}