import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; import 'package:tane/db/enums.dart'; import 'package:tane/state/inventory_cubit.dart'; import '../support/test_support.dart'; /// A repository whose inventory stream errors until [healthy] is flipped — /// mimics the transient DB-not-ready failure that used to leave the spinner /// stuck forever. class _FailingRepository extends VarietyRepository { // ignore: use_super_parameters _FailingRepository(AppDatabase db) : super(db, idGen: IdGen(), nodeId: 'test-node'); bool healthy = false; @override Stream<({List items, List drafts})> watchInventoryView() => healthy ? super.watchInventoryView() : Stream.error(StateError('boom')); } /// Waits until the cubit's state satisfies [predicate], whether it already /// does or a later stream emission gets it there. Future waitFor( InventoryCubit cubit, bool Function(InventoryState) predicate, ) async { if (predicate(cubit.state)) return cubit.state; return cubit.stream.firstWhere(predicate).timeout(const Duration(seconds: 5)); } void main() { late AppDatabase db; late VarietyRepository repo; late InventoryCubit cubit; setUp(() { db = newTestDatabase(); repo = newTestRepository(db); cubit = InventoryCubit(repo); }); tearDown(() async { await cubit.close(); await db.close(); }); test('subscribes to the inventory and leaves loading', () async { await repo.addQuickVariety(label: 'Maize'); final state = await waitFor(cubit, (s) => !s.loading && s.items.isNotEmpty); expect(state.items.single.label, 'Maize'); }); group('stream failure', () { test('a failing stream drops loading and surfaces an error, not a ' 'forever-spinner', () async { final failing = _FailingRepository(db); final failCubit = InventoryCubit(failing); addTearDown(failCubit.close); final state = await waitFor(failCubit, (s) => s.error != null); expect(state.loading, isFalse); expect(state.error, contains('boom')); }); test('retry re-opens the stream and recovers once the DB is healthy', () async { final failing = _FailingRepository(db); final failCubit = InventoryCubit(failing); addTearDown(failCubit.close); await waitFor(failCubit, (s) => s.error != null); await failing.addQuickVariety(label: 'Maize'); failing.healthy = true; failCubit.retry(); final state = await waitFor(failCubit, (s) => s.error == null && !s.loading); expect(state.items.single.label, 'Maize'); }); }); test('drafts stay in the tray and never mix into the list', () async { await repo.addDraftVariety(Uint8List.fromList([1, 2, 3])); var state = await waitFor(cubit, (s) => s.drafts.isNotEmpty); expect(state.items, isEmpty); // Naming the draft promotes it: tray empties, list gains the item. await repo.nameDraft(state.drafts.single.id, 'Named at last'); state = await waitFor(cubit, (s) => s.items.isNotEmpty && s.drafts.isEmpty); expect(state.items.single.label, 'Named at last'); }); test('search narrows visibleItems by label, case-insensitively', () async { await repo.addQuickVariety(label: 'Tomate rosa'); await repo.addQuickVariety(label: 'Lechuga'); await waitFor(cubit, (s) => s.items.length == 2); cubit.search('TOMA'); expect(cubit.state.visibleItems.single.label, 'Tomate rosa'); cubit.search(''); expect(cubit.state.visibleItems, hasLength(2)); }); test('toggleCategory adds then removes the filter', () async { await repo.addQuickVariety(label: 'Maize', category: 'Poaceae'); await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae'); await waitFor(cubit, (s) => s.items.length == 2); cubit.toggleCategory('Poaceae'); expect(cubit.state.visibleItems.single.label, 'Maize'); cubit.toggleCategory('Poaceae'); expect(cubit.state.visibleItems, hasLength(2)); }); test('toggleType keeps only varieties holding a lot of that form', () async { final seedId = await repo.addQuickVariety(label: 'Seedy'); await repo.addLot(varietyId: seedId, type: LotType.seed); final plantId = await repo.addQuickVariety(label: 'Planty'); await repo.addLot(varietyId: plantId, type: LotType.plant); await waitFor( cubit, (s) => s.items.length == 2 && s.items.every((i) => i.lotTypes.isNotEmpty), ); cubit.toggleType(LotType.plant); expect(cubit.state.visibleItems.single.label, 'Planty'); }); test('organic and needs-reproduction filters', () async { final ecoId = await repo.addQuickVariety(label: 'Eco'); await repo.updateVariety(id: ecoId, isOrganic: true); final tiredId = await repo.addQuickVariety(label: 'Tired'); await repo.updateVariety(id: tiredId, needsReproduction: true); await waitFor(cubit, (s) => s.hasOrganic && s.hasNeedsReproduction); cubit.toggleOrganicOnly(); expect(cubit.state.visibleItems.single.label, 'Eco'); cubit.toggleOrganicOnly(); cubit.toggleNeedsReproductionOnly(); expect(cubit.state.visibleItems.single.label, 'Tired'); }); test('sharing filter keeps only varieties with an offered lot', () async { final sharedId = await repo.addQuickVariety(label: 'Shared'); await repo.addLot(varietyId: sharedId, offerStatus: OfferStatus.shared); final privateId = await repo.addQuickVariety(label: 'Private'); await repo.addLot(varietyId: privateId); await waitFor(cubit, (s) => s.hasShared && s.items.length == 2); cubit.toggleSharingOnly(); expect(cubit.state.visibleItems.single.label, 'Shared'); cubit.toggleSharingOnly(); expect(cubit.state.visibleItems, hasLength(2)); }); test('clearFilters resets every filter but keeps the search query', () async { await repo.addQuickVariety(label: 'Maize', category: 'Poaceae'); await waitFor(cubit, (s) => s.items.isNotEmpty); cubit ..search('mai') ..toggleCategory('Poaceae') ..toggleType(LotType.seed) ..toggleOrganicOnly() ..toggleNeedsReproductionOnly() ..toggleSharingOnly(); cubit.clearFilters(); expect(cubit.state.categoryFilter, isEmpty); expect(cubit.state.typeFilter, isEmpty); expect(cubit.state.organicOnly, isFalse); expect(cubit.state.needsReproductionOnly, isFalse); expect(cubit.state.sharingOnly, isFalse); expect(cubit.state.query, 'mai'); }); test('categories dedupes and preserves first-seen order', () async { await repo.addQuickVariety(label: 'A', category: 'Poaceae'); await repo.addQuickVariety(label: 'B', category: 'Fabaceae'); await repo.addQuickVariety(label: 'C', category: 'Poaceae'); final state = await waitFor(cubit, (s) => s.items.length == 3); expect(state.categories, hasLength(2)); expect(state.categories.toSet(), {'Poaceae', 'Fabaceae'}); }); group('selection mode', () { test('enterSelection starts the mode with one id', () async { await repo.addQuickVariety(label: 'Bean'); final state = await waitFor(cubit, (s) => s.items.isNotEmpty); final id = state.items.single.id; cubit.enterSelection(id); expect(cubit.state.selectionMode, isTrue); expect(cubit.state.selectedIds, {id}); }); test('toggleSelection adds then removes, staying in mode', () async { await repo.addQuickVariety(label: 'Bean'); final state = await waitFor(cubit, (s) => s.items.isNotEmpty); final id = state.items.single.id; cubit.toggleSelection(id); expect(cubit.state.selectedIds, {id}); cubit.toggleSelection(id); expect(cubit.state.selectedIds, isEmpty); expect(cubit.state.selectionMode, isTrue); }); test('selectAllVisible selects only the filtered subset', () async { await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae'); await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae'); final state = await waitFor(cubit, (s) => s.items.length == 2); final beanId = state.items.firstWhere((i) => i.label == 'Bean').id; cubit.toggleCategory('Fabaceae'); // hide the tomato cubit.selectAllVisible(); expect(cubit.state.selectedIds, {beanId}); }); test('exitSelection leaves the mode and clears the selection', () async { await repo.addQuickVariety(label: 'Bean'); final state = await waitFor(cubit, (s) => s.items.isNotEmpty); cubit.enterSelection(state.items.single.id); cubit.exitSelection(); expect(cubit.state.selectionMode, isFalse); expect(cubit.state.selectedIds, isEmpty); }); }); }