Instead of showing a retry button on the first failure, silently re-open the inventory stream with exponential backoff (250ms→4s, 6 attempts, staying in the loading spinner). The startup DB-not-ready race clears in well under a second, so the user just sees the spinner briefly and the list appears — no tap needed. Only after the retry budget is exhausted do we surface the manual-retry error. Tests cover auto-recovery, no error flicker during retries, and the give-up path (via fake_async to fast-forward the backoff).
264 lines
9.8 KiB
Dart
264 lines
9.8 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:fake_async/fake_async.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 for the first
|
|
/// [failuresBeforeHealthy] subscriptions, then behaves normally — mimics the
|
|
/// transient DB-not-ready failure that used to leave the spinner stuck forever.
|
|
/// The default never recovers, for the "give up" path.
|
|
class _FailingRepository extends VarietyRepository {
|
|
// ignore: use_super_parameters
|
|
_FailingRepository(AppDatabase db, {this.failuresBeforeHealthy = 1 << 30})
|
|
: super(db, idGen: IdGen(), nodeId: 'test-node');
|
|
|
|
final int failuresBeforeHealthy;
|
|
int subscriptions = 0;
|
|
|
|
@override
|
|
Stream<({List<VarietyListItem> items, List<VarietyListItem> drafts})>
|
|
watchInventoryView() => subscriptions++ < failuresBeforeHealthy
|
|
? Stream.error(StateError('boom'))
|
|
: super.watchInventoryView();
|
|
}
|
|
|
|
/// 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 transient failure recovers on its own — no error, no manual retry',
|
|
() async {
|
|
// Fails the first two subscribes, then the DB is ready.
|
|
final failing = _FailingRepository(db, failuresBeforeHealthy: 2);
|
|
await failing.addQuickVariety(label: 'Maize');
|
|
final failCubit = InventoryCubit(failing);
|
|
addTearDown(failCubit.close);
|
|
|
|
// Auto-retry with backoff brings it back without ever showing an error.
|
|
final state = await waitFor(failCubit, (s) => !s.loading && s.error == null);
|
|
expect(state.items.single.label, 'Maize');
|
|
expect(failing.subscriptions, greaterThan(1));
|
|
});
|
|
|
|
test('stays in loading (spinner), never flips to error, while auto-retrying',
|
|
() async {
|
|
final failing = _FailingRepository(db, failuresBeforeHealthy: 2);
|
|
await failing.addQuickVariety(label: 'Maize');
|
|
final failCubit = InventoryCubit(failing);
|
|
addTearDown(failCubit.close);
|
|
|
|
final errors = <String?>[];
|
|
final sub = failCubit.stream.listen((s) => errors.add(s.error));
|
|
addTearDown(sub.cancel);
|
|
|
|
await waitFor(failCubit, (s) => !s.loading && s.error == null);
|
|
expect(errors.every((e) => e == null), isTrue,
|
|
reason: 'no error state should be emitted during auto-recovery');
|
|
});
|
|
|
|
test('gives up after the retry budget and surfaces a manual-retry error',
|
|
() {
|
|
fakeAsync((async) {
|
|
final failing = _FailingRepository(db); // never recovers
|
|
final failCubit = InventoryCubit(failing);
|
|
addTearDown(failCubit.close);
|
|
|
|
// Fast-forward past all backoff windows (~12s of retries).
|
|
async.elapse(const Duration(seconds: 30));
|
|
|
|
expect(failCubit.state.loading, isFalse);
|
|
expect(failCubit.state.error, contains('boom'));
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|