import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/l10n/formato_fechas.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/alarma_musical.dart'; import 'package:pluriwave/pantallas/pantalla_vacaciones.dart'; import 'package:pluriwave/servicios/servicio_alarmas.dart'; import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes_alarmas.dart'; /// WU9, `alarm-vacation-ranges` spec — the real Vacaciones manager screen /// that WU8's summary row pushes to (replacing WU8's own temporary /// placeholder). Covers: active-range hero (name + days-remaining countdown /// + per-alarm impact line), the "PROGRAMADOS" upcoming list, the "Añadir /// rango" CTA, and the "Rangos pasados" history section. /// /// Clock note: `PantallaVacaciones` calls `EstadoAlarmas.rangoVacacionesActivo()` /// etc. with NO `ahora` argument (production code always uses real /// `DateTime.now()` — design ADR-6 explicitly rejects a testable Clock /// abstraction as out of scope for this redesign). So fixtures here are /// built RELATIVE to `DateTime.now()`, never as hardcoded calendar dates — /// the `ServicioAlarmas`'s own `reloj` (used only for alarm SCHEDULING /// maths, irrelevant to the vacation-query assertions below) is left at its /// real-clock default too, for consistency. DateTime get _hoy => DateTime.now(); DateTime get _hoyDia => DateTime(_hoy.year, _hoy.month, _hoy.day); Future _crearEstado({ List alarmas = const [], List vacaciones = const [], }) async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( esPremium: () => true, servicio: ServicioAlarmas(), android: android, iniciarAutomaticamente: false, ); for (final alarma in alarmas) { await estado.guardarAlarma(alarma); } if (vacaciones.isNotEmpty) { await estado.guardarVacaciones(vacaciones); } return estado; } Widget _buildScreen(EstadoAlarmas estado) { return ChangeNotifierProvider.value( value: estado, child: MaterialApp( locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const PantallaVacaciones(), ), ); } Future _pumpEstable(WidgetTester tester) async { await tester.pump(); await tester.pumpAndSettle(); } void main() { setUp(() { SharedPreferences.setMockInitialValues({}); }); testWidgets( 'renders inside a PluriPushScaffold; con un rango activo muestra su ' 'nombre, los dias restantes, y la linea de impacto por alarma', (tester) async { final estado = await _crearEstado( alarmas: [ const AlarmaMusical( id: 'p1', nombre: 'Pausada 1', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], sonarEnVacaciones: false, ), const AlarmaMusical( id: 'p2', nombre: 'Pausada 2', hora: 13, minuto: 45, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], sonarEnVacaciones: false, ), const AlarmaMusical( id: 'n1', nombre: 'Sigue', hora: 8, minuto: 15, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ], vacaciones: [ RangoVacaciones( id: 'v1', nombre: 'Julio activo', inicio: _hoyDia.subtract(const Duration(days: 3)), fin: _hoyDia.add(const Duration(days: 5)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); expect(find.byType(PluriPushScaffold), findsOneWidget); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); expect(find.text('Julio activo'), findsOneWidget); expect(find.text(l10n.vacationSummaryActiveCountdown(5)), findsOneWidget); expect( find.text(l10n.vacationImpactPausedLabel('07:30, 13:45')), findsOneWidget, ); expect( find.text(l10n.vacationImpactContinuesLabel('08:15')), findsOneWidget, ); }, ); testWidgets('sin rango activo, muestra la pista en su lugar', (tester) async { final estado = await _crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget); }); testWidgets('los rangos futuros aparecen bajo el encabezado PROGRAMADOS', ( tester, ) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'f1', nombre: 'Rango lejano', inicio: _hoyDia.add(const Duration(days: 120)), fin: _hoyDia.add(const Duration(days: 125)), ), RangoVacaciones( id: 'f2', nombre: 'Rango cercano', inicio: _hoyDia.add(const Duration(days: 20)), fin: _hoyDia.add(const Duration(days: 25)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); expect(find.text(l10n.vacationUpcomingSectionTitle), findsOneWidget); expect(find.text('Rango lejano'), findsOneWidget); expect(find.text('Rango cercano'), findsOneWidget); }); testWidgets( 'los rangos pasados aparecen bajo el encabezado "Rangos pasados" tras ' 'expandir la fila (audit 9b.7: colapsada por defecto, t4:489)', (tester) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'pa1', nombre: 'Rango viejo', inicio: _hoyDia.subtract(const Duration(days: 30)), fin: _hoyDia.subtract(const Duration(days: 20)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); expect(find.text(l10n.vacationPastSectionTitle), findsOneWidget); await tester.tap(find.text(l10n.vacationPastSectionTitle)); await _pumpEstable(tester); expect(find.text('Rango viejo'), findsOneWidget); }, ); testWidgets('el CTA "Añadir rango" abre el formulario de alta existente', ( tester, ) async { final estado = await _crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); await tester.tap(find.text(l10n.addVacationRangeCta)); await _pumpEstable(tester); expect(find.text(l10n.newVacationRangeTitle), findsOneWidget); }); // Item 21 / audit 9b.4-9b.5 (t4:451-462, 469-479): the screen's signature // element is a start/end date pair, never a determinate progress bar. testWidgets( 'el rango activo muestra el par de fechas, no una barra de progreso', (tester) async { final inicio = _hoyDia.subtract(const Duration(days: 3)); final fin = _hoyDia.add(const Duration(days: 5)); final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'v1', nombre: 'Julio activo', inicio: inicio, fin: fin, ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); expect( find.byType(LinearProgressIndicator), findsNothing, reason: 't4:451-462 replaces the progress bar with a start/end date ' 'pair — the screen\'s signature element', ); expect(find.text(diaMesLocalizado('es', inicio)), findsOneWidget); expect(find.text(diaMesLocalizado('es', fin)), findsOneWidget); expect( find.text(nombreDiaSemanaLocalizado('es', inicio)), findsOneWidget, ); expect(find.text(nombreDiaSemanaLocalizado('es', fin)), findsOneWidget); final fechaTexto = tester.widget( find.text(diaMesLocalizado('es', inicio)), ); expect( fechaTexto.style?.fontSize, 26, reason: 't4:459 the date digits are 26px/w800/ls-1', ); expect(fechaTexto.style?.fontWeight, FontWeight.w800); final diaSemanaTexto = tester.widget( find.text(nombreDiaSemanaLocalizado('es', inicio)), ); expect( diaSemanaTexto.style?.fontSize, 11, reason: 't4:459 the weekday caption is 11px/w700/50%', ); expect(diaSemanaTexto.style?.fontWeight, FontWeight.w700); final regla = tester.widget( find.byKey(const ValueKey('vacaciones-regla-activo')), ); final decoration = regla.decoration as BoxDecoration; expect( decoration.gradient, isNotNull, reason: 't4:460 the active range connector is a brand-teal gradient, ' 'not the flat rgba(255,255,255,.14) used by scheduled rows', ); }, ); testWidgets( 'un rango programado usa el mismo par de fechas, con una regla plana y ' 'una fila de etiqueta con el nombre del rango', (tester) async { final inicio = _hoyDia.add(const Duration(days: 20)); final fin = _hoyDia.add(const Duration(days: 25)); final estado = await _crearEstado( vacaciones: [ RangoVacaciones(id: 'f2', nombre: 'Verano', inicio: inicio, fin: fin), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); expect( find.descendant( of: find.byType(PantallaVacaciones), matching: find.byType(ListTile), ), findsNothing, reason: '9b.5 (t4:469-479) replaces the ListTile row with a card', ); expect(find.text(diaMesLocalizado('es', inicio)), findsOneWidget); expect(find.text(diaMesLocalizado('es', fin)), findsOneWidget); expect( find.text('Verano'), findsOneWidget, reason: 't4:478 the label row shows the range name next to a ' '"label" icon', ); final regla = tester.widget( find.byKey(const ValueKey('vacaciones-regla-f2')), ); final decoration = regla.decoration as BoxDecoration; expect(decoration.gradient, isNull); expect( decoration.color, Colors.white.withValues(alpha: 0.14), reason: 't4:474 scheduled/past rows use a flat rgba(255,255,255,.14) ' 'connector, not the active gradient', ); }, ); group('visual fidelity (audit 9b.1/9b.2/9b.6/9b.7)', () { testWidgets( '9b.1: a header "Add" action is reachable and opens the same form ' 'as the CTA (t4:446)', (tester) async { final estado = await _crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); final appBar = tester.widget(find.byType(AppBar)); expect(appBar.actions, isNotNull); expect(appBar.actions, isNotEmpty); await tester.tap(find.byKey(const ValueKey('vacation-add-header'))); await _pumpEstable(tester); expect(find.text(l10n.newVacationRangeTitle), findsOneWidget); }, ); testWidgets( '9b.2: the explanatory info banner is always visible (t4:448)', (tester) async { final estado = await _crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); expect(find.text(l10n.vacationExplainerBanner), findsOneWidget); }, ); testWidgets( '9b.6: the bottom CTA has a dashed border and a date_range icon ' '(t4:487)', (tester) async { final estado = await _crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); expect(find.byIcon(Icons.date_range_rounded), findsOneWidget); // Exactly ONE add_rounded on the whole screen -- the header's OWN // "Add" action (audit 9b.1). The bottom CTA no longer uses it. expect(find.byIcon(Icons.add_rounded), findsOneWidget); }, ); testWidgets( '9b.7: "Rangos pasados" is collapsed by default, showing a count, ' 'and expands on tap (t4:489)', (tester) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'pa1', nombre: 'Rango viejo', inicio: _hoyDia.subtract(const Duration(days: 30)), fin: _hoyDia.subtract(const Duration(days: 20)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); expect(find.text(l10n.vacationPastSectionTitle), findsOneWidget); expect(find.text('1'), findsOneWidget); expect( find.text('Rango viejo'), findsNothing, reason: 'collapsed by default -- matches the prototype count-only row', ); await tester.tap(find.text(l10n.vacationPastSectionTitle)); await _pumpEstable(tester); expect(find.text('Rango viejo'), findsOneWidget); }, ); }); group('issue 1 (feedback-pruebas): editar y eliminar rangos', () { testWidgets( 'tocar la tarjeta de un rango programado abre el editor precargado ' 'con su nombre y fechas', (tester) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'f2', nombre: 'Verano', inicio: _hoyDia.add(const Duration(days: 20)), fin: _hoyDia.add(const Duration(days: 25)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2'))); await _pumpEstable(tester); expect(find.text(l10n.editVacationRangeTitle), findsOneWidget); // Scoped to the TextField specifically -- the original card's OWN // "Verano" label is still (offstage, behind the modal) in the tree, // so a bare `find.text('Verano')` would ambiguously match both. expect(find.widgetWithText(TextField, 'Verano'), findsOneWidget); }, ); testWidgets( 'guardar el editor abierto por tap actualiza el rango existente (no ' 'crea uno nuevo)', (tester) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'f2', nombre: 'Verano', inicio: _hoyDia.add(const Duration(days: 20)), fin: _hoyDia.add(const Duration(days: 25)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2'))); await _pumpEstable(tester); await tester.enterText(find.byType(TextField), 'Verano renombrado'); final boton = tester.widget( find.widgetWithText(FilledButton, l10n.saveRangeAction), ); boton.onPressed!(); await _pumpEstable(tester); expect(estado.vacaciones, hasLength(1)); expect(estado.vacaciones.single.id, 'f2'); expect(estado.vacaciones.single.nombre, 'Verano renombrado'); }, ); testWidgets( 'deslizar la tarjeta de un rango pide confirmacion; cancelar la ' 'conserva y confirmar la elimina', (tester) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'f2', nombre: 'Verano', inicio: _hoyDia.add(const Duration(days: 20)), fin: _hoyDia.add(const Duration(days: 25)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); // Cancelar: el rango se conserva. await tester.drag( find.byKey(const ValueKey('vacaciones-tarjeta-f2')), const Offset(-600, 0), ); await _pumpEstable(tester); expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget); await tester.tap(find.text(l10n.cancelAction)); await _pumpEstable(tester); expect(estado.vacaciones, hasLength(1)); // Confirmar: el rango se elimina. await tester.drag( find.byKey(const ValueKey('vacaciones-tarjeta-f2')), const Offset(-600, 0), ); await _pumpEstable(tester); expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget); await tester.tap(find.text(l10n.deleteAction)); await _pumpEstable(tester); expect(estado.vacaciones, isEmpty); }, ); testWidgets( 'el rango ACTIVO (mostrado en el hero) tambien se puede editar (tap) ' 'y eliminar (swipe) -- un rango recien creado siempre esta activo y ' 'nunca aparece en las listas programado/pasado', (tester) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'v1', nombre: 'Julio activo', inicio: _hoyDia.subtract(const Duration(days: 3)), fin: _hoyDia.add(const Duration(days: 5)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-v1'))); await _pumpEstable(tester); expect(find.text(l10n.editVacationRangeTitle), findsOneWidget); // Scoped to the TextField specifically -- the hero's OWN "Julio // activo" label is still (offstage, behind the modal) in the tree. expect(find.widgetWithText(TextField, 'Julio activo'), findsOneWidget); // Dismiss the editor sheet (no explicit close button -- same as the // pre-existing "Anadir rango" sheet, dismissible via the standard // modal-bottom-sheet Navigator.pop) before interacting with the // list underneath it. Navigator.of(tester.element(find.byType(PantallaVacaciones))).pop(); await _pumpEstable(tester); await tester.drag( find.byKey(const ValueKey('vacaciones-tarjeta-v1')), const Offset(-600, 0), ); await _pumpEstable(tester); await tester.tap(find.text(l10n.deleteAction)); await _pumpEstable(tester); expect(estado.vacaciones, isEmpty); expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget); }, ); }); group('fix vacaciones-delete: el editor ofrece una accion de eliminar solo ' 'al editar un rango existente, reusando _confirmarEliminarRango y ' 'eliminarRangoVacaciones exactamente como el swipe', () { testWidgets( 'creando un rango NUEVO (CTA "Anadir rango"), el editor NO muestra ' 'una accion de eliminar -- no hay nada que borrar todavia', (tester) async { final estado = await _crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); await tester.tap(find.text(l10n.addVacationRangeCta)); await _pumpEstable(tester); expect(find.text(l10n.newVacationRangeTitle), findsOneWidget); expect( find.byKey(const ValueKey('vacation-delete-button')), findsNothing, ); }, ); testWidgets('editando un rango EXISTENTE (tap en su tarjeta), el editor SI ' 'muestra una accion de eliminar junto al boton de guardar', ( tester, ) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'f2', nombre: 'Verano', inicio: _hoyDia.add(const Duration(days: 20)), fin: _hoyDia.add(const Duration(days: 25)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2'))); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); expect(find.text(l10n.editVacationRangeTitle), findsOneWidget); expect( find.byKey(const ValueKey('vacation-delete-button')), findsOneWidget, ); }); testWidgets( 'tocar eliminar en el editor pide confirmacion (misma que el swipe); ' 'cancelar conserva el rango y el editor sigue abierto', (tester) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'f2', nombre: 'Verano', inicio: _hoyDia.add(const Duration(days: 20)), fin: _hoyDia.add(const Duration(days: 25)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2'))); await _pumpEstable(tester); await tester.tap(find.byKey(const ValueKey('vacation-delete-button'))); await _pumpEstable(tester); expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget); await tester.tap(find.text(l10n.cancelAction)); await _pumpEstable(tester); expect(estado.vacaciones, hasLength(1)); expect(find.text(l10n.editVacationRangeTitle), findsOneWidget); }, ); testWidgets('tocar eliminar en el editor y confirmar llama a ' 'eliminarRangoVacaciones y cierra el editor (mismo efecto que el ' 'swipe, sin pasar por _guardar)', (tester) async { final estado = await _crearEstado( vacaciones: [ RangoVacaciones( id: 'f2', nombre: 'Verano', inicio: _hoyDia.add(const Duration(days: 20)), fin: _hoyDia.add(const Duration(days: 25)), ), ], ); addTearDown(estado.dispose); await tester.pumpWidget(_buildScreen(estado)); await _pumpEstable(tester); final l10n = AppLocalizations.of( tester.element(find.byType(PantallaVacaciones)), ); await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2'))); await _pumpEstable(tester); await tester.tap(find.byKey(const ValueKey('vacation-delete-button'))); await _pumpEstable(tester); expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget); // `find.widgetWithText(FilledButton, ...)`, not a bare // `find.text(...)`: the sheet's own OutlinedButton delete action // (same "Eliminar" label) is still in the tree behind the dialog, // so a bare text finder would ambiguously match both. await tester.tap(find.widgetWithText(FilledButton, l10n.deleteAction)); await _pumpEstable(tester); expect(estado.vacaciones, isEmpty); expect( find.text(l10n.editVacationRangeTitle), findsNothing, reason: 'the sheet must pop, exactly like a successful _guardar', ); }); }); }