test(state): cover InventoryCubit and VarietyDetailCubit; refresh block1 status doc
This commit is contained in:
parent
a96d82add9
commit
cf5d302cc1
4 changed files with 292 additions and 47 deletions
134
apps/app_seeds/test/state/inventory_cubit_test.dart
Normal file
134
apps/app_seeds/test/state/inventory_cubit_test.dart
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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();
|
||||||
|
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.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'});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -19,35 +19,39 @@ void main() {
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('submitAndAddAnother with an empty label errors and saves nothing',
|
test(
|
||||||
() async {
|
'submitAndAddAnother with an empty label errors and saves nothing',
|
||||||
await cubit.submitAndAddAnother();
|
() async {
|
||||||
expect(cubit.state.showLabelError, isTrue);
|
await cubit.submitAndAddAnother();
|
||||||
expect(cubit.state.addedCount, 0);
|
expect(cubit.state.showLabelError, isTrue);
|
||||||
expect(await db.select(db.varieties).get(), isEmpty);
|
expect(cubit.state.addedCount, 0);
|
||||||
});
|
expect(await db.select(db.varieties).get(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('submitAndAddAnother saves, resets the form and keeps the lot type',
|
test(
|
||||||
() async {
|
'submitAndAddAnother saves, resets the form and keeps the lot type',
|
||||||
cubit
|
() async {
|
||||||
..setLotType(LotType.plant)
|
cubit
|
||||||
..labelChanged('Basil')
|
..setLotType(LotType.plant)
|
||||||
..setQuantity(const Quantity(kind: QuantityKind.plant, count: 3));
|
..labelChanged('Basil')
|
||||||
|
..setQuantity(const Quantity(kind: QuantityKind.plant, count: 3));
|
||||||
|
|
||||||
await cubit.submitAndAddAnother();
|
await cubit.submitAndAddAnother();
|
||||||
|
|
||||||
// Saved to the DB.
|
// Saved to the DB.
|
||||||
final varieties = await db.select(db.varieties).get();
|
final varieties = await db.select(db.varieties).get();
|
||||||
expect(varieties.single.label, 'Basil');
|
expect(varieties.single.label, 'Basil');
|
||||||
|
|
||||||
// Form reset for the next one, but the chosen lot type is kept and the
|
// Form reset for the next one, but the chosen lot type is kept and the
|
||||||
// sheet is NOT told to close (submitted stays false).
|
// sheet is NOT told to close (submitted stays false).
|
||||||
expect(cubit.state.label, '');
|
expect(cubit.state.label, '');
|
||||||
expect(cubit.state.quantity, isNull);
|
expect(cubit.state.quantity, isNull);
|
||||||
expect(cubit.state.lotType, LotType.plant);
|
expect(cubit.state.lotType, LotType.plant);
|
||||||
expect(cubit.state.submitted, isFalse);
|
expect(cubit.state.submitted, isFalse);
|
||||||
expect(cubit.state.addedCount, 1);
|
expect(cubit.state.addedCount, 1);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('addedCount accumulates across several saves', () async {
|
test('addedCount accumulates across several saves', () async {
|
||||||
cubit.labelChanged('One');
|
cubit.labelChanged('One');
|
||||||
|
|
|
||||||
108
apps/app_seeds/test/state/variety_detail_cubit_test.dart
Normal file
108
apps/app_seeds/test/state/variety_detail_cubit_test.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
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/variety_detail_cubit.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
/// Waits until the cubit's state satisfies [predicate], whether it already
|
||||||
|
/// does or a later stream emission gets it there.
|
||||||
|
Future<VarietyDetailState> waitFor(
|
||||||
|
VarietyDetailCubit cubit,
|
||||||
|
bool Function(VarietyDetailState) 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;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = newTestDatabase();
|
||||||
|
repo = newTestRepository(db);
|
||||||
|
});
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
Future<(VarietyDetailCubit, String)> newCubit() async {
|
||||||
|
final id = await repo.addQuickVariety(label: 'Maize');
|
||||||
|
return (VarietyDetailCubit(repo, id), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('streams the detail and leaves loading', () async {
|
||||||
|
final (cubit, _) = await newCubit();
|
||||||
|
final state = await waitFor(cubit, (s) => s.detail != null);
|
||||||
|
expect(state.detail!.label, 'Maize');
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateFields re-emits the edited detail', () async {
|
||||||
|
final (cubit, _) = await newCubit();
|
||||||
|
await waitFor(cubit, (s) => s.detail != null);
|
||||||
|
|
||||||
|
await cubit.updateFields(label: 'Corn', notes: 'From the fair');
|
||||||
|
final state = await waitFor(cubit, (s) => s.detail?.label == 'Corn');
|
||||||
|
expect(state.detail!.notes, 'From the fair');
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addLot, updateLot and deleteLot flow through the stream', () async {
|
||||||
|
final (cubit, _) = await newCubit();
|
||||||
|
await waitFor(cubit, (s) => s.detail != null);
|
||||||
|
|
||||||
|
await cubit.addLot(
|
||||||
|
year: 2024,
|
||||||
|
quantity: const Quantity(kind: QuantityKind.cob, count: 2),
|
||||||
|
);
|
||||||
|
var state = await waitFor(cubit, (s) => s.detail?.lots.isNotEmpty == true);
|
||||||
|
final lot = state.detail!.lots.single;
|
||||||
|
expect(lot.harvestYear, 2024);
|
||||||
|
|
||||||
|
await cubit.updateLot(lotId: lot.id, type: LotType.seed, year: 2023);
|
||||||
|
state = await waitFor(
|
||||||
|
cubit,
|
||||||
|
(s) => s.detail?.lots.single.harvestYear == 2023,
|
||||||
|
);
|
||||||
|
|
||||||
|
await cubit.deleteLot(lot.id);
|
||||||
|
state = await waitFor(cubit, (s) => s.detail?.lots.isEmpty == true);
|
||||||
|
expect(state.detail!.lots, isEmpty);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vernacular names can be added and removed', () async {
|
||||||
|
final (cubit, _) = await newCubit();
|
||||||
|
await waitFor(cubit, (s) => s.detail != null);
|
||||||
|
|
||||||
|
await cubit.addVernacularName('Millo');
|
||||||
|
var state = await waitFor(
|
||||||
|
cubit,
|
||||||
|
(s) => s.detail?.vernacularNames.isNotEmpty == true,
|
||||||
|
);
|
||||||
|
expect(state.detail!.vernacularNames.single.name, 'Millo');
|
||||||
|
|
||||||
|
await cubit.removeVernacularName(state.detail!.vernacularNames.single.id);
|
||||||
|
state = await waitFor(
|
||||||
|
cubit,
|
||||||
|
(s) => s.detail?.vernacularNames.isEmpty == true,
|
||||||
|
);
|
||||||
|
expect(state.detail!.vernacularNames, isEmpty);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteVariety flags deleted and the stream then yields null', () async {
|
||||||
|
final (cubit, _) = await newCubit();
|
||||||
|
await waitFor(cubit, (s) => s.detail != null);
|
||||||
|
|
||||||
|
await cubit.deleteVariety();
|
||||||
|
expect(cubit.state.deleted, isTrue);
|
||||||
|
|
||||||
|
// The reactive watch notices the tombstone and re-emits null.
|
||||||
|
final state = await waitFor(cubit, (s) => s.detail == null);
|
||||||
|
expect(state.deleted, isTrue);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -30,31 +30,30 @@ JSON por locale, compatible Weblate). Ver [`../../apps/app_seeds`](../../apps/ap
|
||||||
- **Catálogo de especies**: 14 hortícolas ibéricas (familia + nombres ES/EN),
|
- **Catálogo de especies**: 14 hortícolas ibéricas (familia + nombres ES/EN),
|
||||||
sembrado idempotente al arrancar, búsqueda + autocompletado.
|
sembrado idempotente al arrancar, búsqueda + autocompletado.
|
||||||
- **Germinación**: pruebas por lote (germinadas/muestra → tasa derivada), badge
|
- **Germinación**: pruebas por lote (germinadas/muestra → tasa derivada), badge
|
||||||
reactivo.
|
reactivo. Avisos de viabilidad por especie (años de viabilidad + año de cosecha).
|
||||||
- **Tests**: 37 verdes, 0 saltados. `analyze` + `format` limpios.
|
- **Schema v8**: procedencia, calendario de cultivo multi-mes (bitmask), escala de
|
||||||
|
abundancia, "necesita reproducción", chequeos de estado, formato de conservación.
|
||||||
|
- **Digitalización masiva**: import CSV aditivo, import/export JSON con
|
||||||
|
reconciliación LWW, borradores foto-first con OCR en dispositivo (es/en),
|
||||||
|
alta rápida "guardar y añadir otra".
|
||||||
|
- **Edición completa**: editar/borrar lotes (`updateLot`/`softDeleteLot`),
|
||||||
|
añadir/quitar nombres vernáculos (OR-Set con tombstones), editar campos,
|
||||||
|
fotos (portada, visor, borrado), enlaces externos.
|
||||||
|
- **Tests**: ~190 verdes, 0 saltados, incl. cubits de estado (`InventoryCubit`,
|
||||||
|
`VarietyDetailCubit`, `QuickAddCubit`). `analyze` + `format` limpios.
|
||||||
|
|
||||||
## Pendiente en el Bloque 1 (pulido) ⏳
|
## Pendiente en el Bloque 1 (pulido) ⏳
|
||||||
|
|
||||||
- **Editar nombres vernáculos** ("también conocida como": hoy solo lectura;
|
- **QR de recuperación de la semilla raíz** + fichero de copia cifrado único y
|
||||||
falta añadir/quitar — colección OR-Set).
|
restauración (ver [`backup-and-recovery.md`](backup-and-recovery.md)) — fase
|
||||||
- **Editar/borrar lotes** (se pueden añadir pero no modificar/eliminar). Falta
|
planificada.
|
||||||
`updateLot`/`softDeleteLot` en el repo (hoy solo `addLot` en
|
- **Comercio local sin red**: UI para `offer_status` (regalo/trueque/venta),
|
||||||
[`variety_repository.dart`](../../apps/app_seeds/lib/data/variety_repository.dart))
|
vista "lo que comparto", catálogo imprimible — fase planificada.
|
||||||
y la afordancia en las tiles de lote de
|
- **Cantidad precisa (gramos/nº) en la UI**: diferida a propósito. "Cuánto tengo"
|
||||||
[`variety_detail_screen.dart`](../../apps/app_seeds/lib/ui/variety_detail_screen.dart).
|
lo responde la **escala de abundancia** (v8); la cantidad numérica se retomará
|
||||||
El soft-delete ya tiene infraestructura (`isDeleted`/tombstone vía `SyncColumns`).
|
cuando las Offers reales la pidan (decisión 2026-07-09).
|
||||||
- **Editar cantidad del lote**: la UI de alta solo ofrece 6 `QuantityKind` como
|
- (Opcional) almacenamiento cifrado de fotos fuera de la BD (hoy BLOB cifrado
|
||||||
chips; falta campo para cantidad numérica (`precise`) y etiqueta libre (`label`),
|
dentro de la BD, que cumple la regla).
|
||||||
que el modelo (`Quantity`) ya soporta.
|
|
||||||
- (Opcional) QR de recuperación de la semilla raíz; almacenamiento cifrado de
|
|
||||||
fotos fuera de la BD (hoy van como BLOB cifrado dentro de la BD, que cumple la
|
|
||||||
regla).
|
|
||||||
|
|
||||||
### Feedback de prueba (vjrj, 2026-07-07)
|
|
||||||
|
|
||||||
Tras probar la app: no se pueden editar unidades/cantidad ni editar/borrar lotes
|
|
||||||
(solo añadir) — **esperado**, cubierto por los pendientes de arriba. Echa en falta
|
|
||||||
la iconografía de los mockups (ver nota siguiente).
|
|
||||||
|
|
||||||
## Iconografía de los mockups (diseño futuro) 🎨
|
## Iconografía de los mockups (diseño futuro) 🎨
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue