E2E TiprePOS — Guía de automatización Windows

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.

MONEYPATH — leé esto primero. La suite escribe 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.

1Stack / herramientas

CapaHerramienta
Driver de testintegration_test (package oficial de Flutter) + flutter_test (WidgetTester)
TargetBuild desktop de Windows (flutter test ... -d windows) — app real
Acceso al estadoflutter_bloc → .read<T>() de los BLoCs reales (BackendClientBloc, TicketBloc, ConfigurationBloc)
Simulación de escánersimulateKeyDownEvent / simulateKeyUpEvent (flutter/services) — keyboard-wedge (dígitos + Enter)
DB local del POSIsar (isar_community) — single-process
BackendSpring 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.

2Prerequisitos (Windows)

3Archivos

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()

4Cómo correr

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

Salida esperada (ej., 10 casos): 00:59 +1: All tests passed!

5Gotchas probados en campo

  1. Isar es single-process. Si hay un flutter run manual abierto contra la misma DB, el test no levanta (lock de archivo). Cerrá todo antes de correr.
  2. El reloj es real. pumpAndSettle NUNCA asienta (timers de 1s + countdown de inactividad). El harness hace polling con pumpUntilFound / pumpUntil.
  3. 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.
  4. Ventana de idempotencia de 30s. El backend matchea por KEY sola, sin filtro de estado → dos ventas a <30s se fusionan en una Trx. El seam resetOpenIdempotencyKeyForTest() rota la key entre canastas. En producción esa ventana expira sola entre clientes.
  5. El scan en Home solo ABRE el ticket. Su item lo borra openTicket (regresión TPOS-011). La canasta se re-escanea DIRECTO en ScanPage.
  6. No toques HomePage para enfocar. Eso pega en el botón "start" y abre un ticket espurio (entryPoint=button). HomePage reclama el foco del escáner solo.
  7. ScanPage tiene DOS controles de cancelar (app bar + bottom bar). Tapeá find.text('CANCELAR COMPRA').hitTestable().first.

6Dashboard en vivo opcional

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.

7Seguridad (moneypath)

8Flujo que ejecuta cada caso

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:

9Template de un caso E2E copiar y adaptar

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).
  });
}