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:
parent
136ed701a7
commit
2812c99280
25 changed files with 2207 additions and 7 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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')));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
150
apps/app_seeds/test/ui/backup_section_test.dart
Normal file
150
apps/app_seeds/test/ui/backup_section_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue