tane/apps/app_seeds/test/state/inventory_cubit_test.dart
vjrj f45c452615 fix(inventory): recover from a stuck loading spinner
The inventory stream subscription had no onError handler, so a transient
failure at startup (e.g. the encrypted DB not yet ready) went unhandled
and left loading=true forever — the spinner that only a restart cleared.

Handle stream errors: drop out of loading, surface an error state, and
offer a retry that re-opens the stream. Add a _LoadError view (i18n en/
es/ast/pt) and cover both the failure and the retry-recovers paths.
2026-07-10 22:12:24 +02:00

241 lines
8.6 KiB
Dart

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<VarietyListItem> items, List<VarietyListItem> 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<InventoryState> 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);
});
});
}