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( 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); }, ); }); }