From cf5d302cc10c4650d114d1f83ee1dc8c2109563a Mon Sep 17 00:00:00 2001 From: vjrj Date: Thu, 9 Jul 2026 22:03:36 +0200 Subject: [PATCH] test(state): cover InventoryCubit and VarietyDetailCubit; refresh block1 status doc --- .../test/state/inventory_cubit_test.dart | 134 ++++++++++++++++++ .../test/state/quick_add_cubit_test.dart | 54 +++---- .../test/state/variety_detail_cubit_test.dart | 108 ++++++++++++++ docs/design/block1-status.md | 43 +++--- 4 files changed, 292 insertions(+), 47 deletions(-) create mode 100644 apps/app_seeds/test/state/inventory_cubit_test.dart create mode 100644 apps/app_seeds/test/state/variety_detail_cubit_test.dart diff --git a/apps/app_seeds/test/state/inventory_cubit_test.dart b/apps/app_seeds/test/state/inventory_cubit_test.dart new file mode 100644 index 0000000..483aed8 --- /dev/null +++ b/apps/app_seeds/test/state/inventory_cubit_test.dart @@ -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 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'}); + }); +} diff --git a/apps/app_seeds/test/state/quick_add_cubit_test.dart b/apps/app_seeds/test/state/quick_add_cubit_test.dart index be2549d..4fdf44b 100644 --- a/apps/app_seeds/test/state/quick_add_cubit_test.dart +++ b/apps/app_seeds/test/state/quick_add_cubit_test.dart @@ -19,35 +19,39 @@ void main() { await db.close(); }); - test('submitAndAddAnother with an empty label errors and saves nothing', - () async { - await cubit.submitAndAddAnother(); - expect(cubit.state.showLabelError, isTrue); - expect(cubit.state.addedCount, 0); - expect(await db.select(db.varieties).get(), isEmpty); - }); + test( + 'submitAndAddAnother with an empty label errors and saves nothing', + () async { + await cubit.submitAndAddAnother(); + expect(cubit.state.showLabelError, isTrue); + expect(cubit.state.addedCount, 0); + expect(await db.select(db.varieties).get(), isEmpty); + }, + ); - test('submitAndAddAnother saves, resets the form and keeps the lot type', - () async { - cubit - ..setLotType(LotType.plant) - ..labelChanged('Basil') - ..setQuantity(const Quantity(kind: QuantityKind.plant, count: 3)); + test( + 'submitAndAddAnother saves, resets the form and keeps the lot type', + () async { + cubit + ..setLotType(LotType.plant) + ..labelChanged('Basil') + ..setQuantity(const Quantity(kind: QuantityKind.plant, count: 3)); - await cubit.submitAndAddAnother(); + await cubit.submitAndAddAnother(); - // Saved to the DB. - final varieties = await db.select(db.varieties).get(); - expect(varieties.single.label, 'Basil'); + // Saved to the DB. + final varieties = await db.select(db.varieties).get(); + expect(varieties.single.label, 'Basil'); - // Form reset for the next one, but the chosen lot type is kept and the - // sheet is NOT told to close (submitted stays false). - expect(cubit.state.label, ''); - expect(cubit.state.quantity, isNull); - expect(cubit.state.lotType, LotType.plant); - expect(cubit.state.submitted, isFalse); - expect(cubit.state.addedCount, 1); - }); + // Form reset for the next one, but the chosen lot type is kept and the + // sheet is NOT told to close (submitted stays false). + expect(cubit.state.label, ''); + expect(cubit.state.quantity, isNull); + expect(cubit.state.lotType, LotType.plant); + expect(cubit.state.submitted, isFalse); + expect(cubit.state.addedCount, 1); + }, + ); test('addedCount accumulates across several saves', () async { cubit.labelChanged('One'); diff --git a/apps/app_seeds/test/state/variety_detail_cubit_test.dart b/apps/app_seeds/test/state/variety_detail_cubit_test.dart new file mode 100644 index 0000000..5cc4bea --- /dev/null +++ b/apps/app_seeds/test/state/variety_detail_cubit_test.dart @@ -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 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(); + }); +} diff --git a/docs/design/block1-status.md b/docs/design/block1-status.md index 54683d6..4d9f410 100644 --- a/docs/design/block1-status.md +++ b/docs/design/block1-status.md @@ -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), sembrado idempotente al arrancar, búsqueda + autocompletado. - **Germinación**: pruebas por lote (germinadas/muestra → tasa derivada), badge - reactivo. -- **Tests**: 37 verdes, 0 saltados. `analyze` + `format` limpios. + reactivo. Avisos de viabilidad por especie (años de viabilidad + año de cosecha). +- **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) ⏳ -- **Editar nombres vernáculos** ("también conocida como": hoy solo lectura; - falta añadir/quitar — colección OR-Set). -- **Editar/borrar lotes** (se pueden añadir pero no modificar/eliminar). Falta - `updateLot`/`softDeleteLot` en el repo (hoy solo `addLot` en - [`variety_repository.dart`](../../apps/app_seeds/lib/data/variety_repository.dart)) - y la afordancia en las tiles de lote de - [`variety_detail_screen.dart`](../../apps/app_seeds/lib/ui/variety_detail_screen.dart). - El soft-delete ya tiene infraestructura (`isDeleted`/tombstone vía `SyncColumns`). -- **Editar cantidad del lote**: la UI de alta solo ofrece 6 `QuantityKind` como - chips; falta campo para cantidad numérica (`precise`) y etiqueta libre (`label`), - 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). +- **QR de recuperación de la semilla raíz** + fichero de copia cifrado único y + restauración (ver [`backup-and-recovery.md`](backup-and-recovery.md)) — fase + planificada. +- **Comercio local sin red**: UI para `offer_status` (regalo/trueque/venta), + vista "lo que comparto", catálogo imprimible — fase planificada. +- **Cantidad precisa (gramos/nº) en la UI**: diferida a propósito. "Cuánto tengo" + lo responde la **escala de abundancia** (v8); la cantidad numérica se retomará + cuando las Offers reales la pidan (decisión 2026-07-09). +- (Opcional) almacenamiento cifrado de fotos fuera de la BD (hoy BLOB cifrado + dentro de la BD, que cumple la regla). ## Iconografía de los mockups (diseño futuro) 🎨