Files
pluriwave/test/pantallas/pantalla_vacaciones_test.dart
T
FreeTLab f61b0b9163 fix(vacaciones): header Add action, info banner, dashed CTA, collapse
Audit 9b.1 (t4:446): a solid brand-teal "Add" header action -- the
prototype's own mid-page CTA (audit 9b.6, still present, now dashed)
is a SECOND, additional entry point, not a replacement.

Audit 9b.2 (t4:448): the teal explanatory banner ("alarms marked
pause-during-vacations won't ring...") is now ALWAYS visible -- never
rendered before. New ARB key vacationExplainerBanner, all 13 locales.

Audit 9b.6 (t4:487): the bottom CTA is now dashed-border with a
date_range glyph -- was a solid OutlinedButton with an add glyph.
Reuses the dashed-painter shape already established in
pantalla_favoritos.dart's custom-station CTA (audit 4.5), duplicated
rather than shared.

Audit 9b.7 (t4:489): "Rangos pasados" is now a collapsible row --
icon, title, count, chevron -- COLLAPSED by default, expanding on tap.
Was always fully expanded inline. Updated the pre-existing widget test
to tap-then-assert instead of asserting immediate visibility.

Item 9b.3 (the eyebrow literally reading "EN CURSO") is NOT
implemented: the eyebrow STYLING is already correct (audit 9b.4), and
the residual copy gap is a shared ARB string
(vacationSummaryActiveCountdown) also used compactly in
pantalla_alarmas.dart's vacation summary row -- diverging its wording
just for this screen's eyebrow, or forcing a shoutier tone into that
other compact usage, is not worth it for a trivial-rated copy nuance.
2026-07-30 16:53:04 +02:00

449 lines
14 KiB
Dart

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<EstadoAlarmas> _crearEstado({
List<AlarmaMusical> alarmas = const [],
List<RangoVacaciones> 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<EstadoAlarmas>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const PantallaVacaciones(),
),
);
}
Future<void> _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<Text>(
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<Text>(
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<DecoratedBox>(
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<DecoratedBox>(
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<AppBar>(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);
},
);
});
}