Suite end-to-end que maneja el POS real de AutoServicio contra el backend real.
No hay mocks ni simulación de REST: se abre la app de verdad (build desktop de Windows de Flutter), se escanean artículos como si viniera del lector, y se asierta sobre el estado interno de los BLoCs.
Trx reales en la base del
backend al que apuntás. Nunca apuntar a producción. Usar un backend de QA con su propia
base. El flujo de pago + cierre consume CAEA fiscal, por eso la suite se detiene en
validado (pre-fiscal). Ver sección 7.
| Capa | Herramienta |
|---|---|
| Driver de test | integration_test (package oficial de Flutter) + flutter_test (WidgetTester) |
| Target | Build desktop de Windows (flutter test ... -d windows) — app real |
| Acceso al estado | flutter_bloc → .read<T>() de los BLoCs reales (BackendClientBloc, TicketBloc, ConfigurationBloc) |
| Simulación de escáner | simulateKeyDownEvent / simulateKeyUpEvent (flutter/services) — keyboard-wedge (dígitos + Enter) |
| DB local del POS | Isar (isar_community) — single-process |
| Backend | Spring Modulith / Spring Boot :8080 + MSSQL |
| Seam de test | @visibleForTesting en home_page.dart (reset de idempotency key) |
| Dashboard en vivo (opc.) | Dart JSONL → server Python http.server → HTML |
El corazón es: integration_test + -d windows + leer los BLoCs reales. Lo
demás es andamiaje alrededor de eso.
flutter doctor.flutter config --enable-windows-desktop.CL).C:\Program Files\Eclipse Adoptium\jdk-21.0.11.10-hotspot).:8080, apuntando a una base de QA (no producción).openTicket falla con "Terminal sin servidor configurado").Todo vive en el repo TiprePOS, bajo integration_test/:
TiprePOS/
├── integration_test/
│ ├── ventas_e2e_test.dart # la suite (loop de N canastas)
│ └── helpers/
│ ├── e2e_harness.dart # boot, scan, pagar→validar, cancelar→home + polling
│ └── bench_reporter.dart # emisor JSONL opt-in (no-op sin BENCH_OUT)
└── lib/views/home_page/home_page.dart # seam: resetOpenIdempotencyKeyForTest()
Desde la raíz de TiprePOS, en una shell tipo bash (Git Bash / MSYS):
export CL="-D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS"
export MSYS_NO_PATHCONV=1
flutter test integration_test/ventas_e2e_test.dart -d windows
CL silencia un warning de MSVC que, si no, ensucia (o corta) el build.MSYS_NO_PATHCONV=1 evita que MSYS convierta los paths /c/... y rompa argumentos._kBaskets en ventas_e2e_test.dart. Subila a gusto (100+ funciona, solo tarda más).Salida esperada (ej., 10 casos): 00:59 +1: All tests passed!
flutter run manual abierto contra la misma DB, el test no levanta (lock de archivo). Cerrá todo antes de correr.pumpAndSettle NUNCA asienta (timers de 1s + countdown de inactividad). El harness hace polling con pumpUntilFound / pumpUntil.go_router persiste el estado entre re-pumps del mismo proceso. Por eso es un solo testWidgets que itera las canastas (single-boot-loop), no N tests.Trx. El seam resetOpenIdempotencyKeyForTest() rota la key entre canastas. En producción esa ventana expira sola entre clientes.openTicket (regresión TPOS-011). La canasta se re-escanea DIRECTO en ScanPage.entryPoint=button). HomePage reclama el foco del escáner solo.ScanPage tiene DOS controles de cancelar (app bar + bottom bar). Tapeá find.text('CANCELAR COMPRA').hitTestable().first.Para ver la corrida en tiempo real (diagrama animado + métricas + feed):
export BENCH_OUT="<ruta>/bench_events.jsonl" # habilita el emisor; sin esto es no-op
flutter test integration_test/ventas_e2e_test.dart -d windows
En paralelo, servir el dashboard:
python serve_bench.py # http://localhost:8750
El test emite un JSON por línea (JSONL); el server Python lo sirve sin cache y el HTML replaya
el stream completo en cada poll. Sin BENCH_OUT, el reporter es no-op total.
MediosPagoPage, no paga ni cierra). Pago + closeTicket consume CAEA fiscal.--app.environment=qa --app.simulation.enabled=true.changeStatusTicket → canceledUser), así quedan CANCELED_USER. Revisá y limpiá Trx en OPEN de corridas interrumpidas (abandonadas, sin fiscal consumido).boot → HomePage → (aplica config de terminal)
→ scan 1er EAN (abre ticket; el item de Home se descarta por TPOS-011)
→ ScanPage: escanea la canasta completa
→ PAGAR → rechaza bolsas → validateTicket → MediosPagoPage ← corta acá (pre-fiscal)
→ VOLVER → CANCELAR COMPRA → "Sí" → HomePage ← deja la Trx CANCELED_USER
Aserciones clave por caso:
ticket.cantidadItems == unidades esperadas (aislamiento exacto).ticket.uIid no nulo y distinto de 0 (abrió una Trx propia).uIid distintos == cantidad de canastas (prueba de aislamiento).Esqueleto de un caso, comentado paso a paso. Se copia a TiprePOS/integration_test/
(ej. mi_caso_e2e_test.dart) y se adapta. Delega TODO en el harness: el caso solo
describe QUÉ se prueba, no CÓMO. El día que la UI cambie un botón, tocás UN helper del harness y
los N casos siguen verdes.
// TEMPLATE — Copy me into TiprePOS/integration_test/ (e.g. mi_caso_e2e_test.dart) and adapt.
// This is a standalone, single-scenario E2E case meant to be read top-to-bottom: it shows the
// canonical POS flow (boot -> scan -> validate -> cancel) with every non-obvious step explained.
//
// Run (backend QA up, no manual `flutter run` holding the Isar lock):
// export CL="-D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS"; export MSYS_NO_PATHCONV=1
// flutter test integration_test/mi_caso_e2e_test.dart -d windows
//
// See README-E2E-TiprePOS for prerequisites, gotchas and the moneypath safety rules.
// Pre-fiscal: this template stops at VALIDATED (does NOT pay or close — no CAEA consumed).
import 'package:auto_compra/services/database/isar_service_impl.dart';
import 'package:auto_compra/viewmodels/ticket/ticket_bloc.dart';
import 'package:auto_compra/views/medios_pago/medios_pago_page.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'helpers/e2e_harness.dart';
// ── 1. The basket under test ───────────────────────────────────────────────
// Each line is (ean, qty). Use real EANs from the catalog (dm_artic) that clear ticket.montoMinimo
// on a single unit and keep the basket under montoMaximoSinIdentificar. Swap these for yours.
const _nariz = '0000010070246'; // NARIZ ~ $2.223
const _manoCara = '0000030001008'; // MANO.CARA ~ $7.437
final List<BasketItem> _basket = [
(ean: _nariz, qty: 2),
(ean: _manoCara, qty: 1),
];
// Expected units in the ticket after scanning. Must match the basket (sum of qty). This is what
// proves the isolation / no-leftover invariant, so compute it from _basket, don't hardcode blindly.
final int _expectedUnits = _basket.fold<int>(0, (acc, b) => acc + b.qty);
// ── 2. Isar is single-process ──────────────────────────────────────────────
// Open the local DB exactly once and reuse it. Never open Isar twice in one process (file lock).
IsarServiceImpl? _isarSingleton;
Future<IsarServiceImpl> _isar() async {
if (_isarSingleton != null) return _isarSingleton!;
final instance = IsarServiceImpl();
await instance.initialize(dbName: 'auto_compra_db');
_isarSingleton = instance;
return instance;
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('mi caso E2E -> ticket validado (pre-fiscal)', (tester) async {
// ── 3. Boot the real app to HomePage ─────────────────────────────────────
// bootToHome also re-applies the saved terminal connection into BackendClientBloc (a plain
// boot does NOT load server/port on its own), so openTicket won't fail with
// "Terminal sin servidor configurado". The terminal must already be registered against QA.
final isar = await _isar();
await bootToHome(tester, isar);
// ── 4. Open + scan the basket ────────────────────────────────────────────
// openAndScanBasket does three things under the hood (all in the harness):
// a) resetOpenIdempotencyKeyForTest() — rotates the 30s idempotency key so THIS ticket is a
// fresh backend Trx (without it, baskets <30s apart merge into one accumulating Trx).
// b) scans the first EAN on Home to OPEN the ticket (that Home item is dropped by TPOS-011),
// c) re-scans the whole basket DIRECTLY on ScanPage via the keyboard-wedge path.
// It returns the real TicketBloc so you can assert on the ticket's internal state.
final TicketBloc ticketBloc = await openAndScanBasket(tester, _basket);
// ── 5. Assert the ticket is exactly what we scanned ──────────────────────
expect(
ticketBloc.state.ticket.cantidadItems,
equals(_expectedUnits),
reason: 'el ticket debe tener EXACTAMENTE $_expectedUnits unidades (aislamiento, sin sobras)',
);
final int? id = ticketBloc.state.ticket.uIid;
expect(
id,
allOf(isNotNull, isNot(0)),
reason: 'el ticket no abrio una Trx propia en el backend (uIid=$id)',
);
// ── 6. PAGAR -> decline bags -> validate ─────────────────────────────────
// Taps PAGAR, answers "No" to the bags dialog, waits for navigation to MediosPagoPage.
// This is the VALIDATED (pre-fiscal) state — validated but nothing paid or closed yet.
await payButtonToValidate(tester);
expect(
find.byType(MediosPagoPage),
findsOneWidget,
reason: 'el ticket no valido (no llego a MediosPago)',
);
// ── 7. Abandon cleanly ───────────────────────────────────────────────────
// Drives the app's OWN cancel flow: MediosPago VOLVER -> ScanPage CANCELAR COMPRA -> "Sí".
// Ends the backend Trx as CANCELED_USER instead of leaving it dangling OPEN. ALWAYS end a case
// with this (or pay+close in a fiscal QA run) so you don't leak OPEN tickets into the DB.
await returnToHome(tester);
// ── 8. (Optional) scale up ───────────────────────────────────────────────
// To run MANY baskets, keep ONE testWidgets and loop here (go_router persists across re-pumps).
// Collect each uIid and assert the set of distinct ids == number of baskets (isolation proof).
// See ventas_e2e_test.dart for the full loop + the live-dashboard hooks (BenchReporter).
});
}