import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/alarma_musical.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart'; import 'package:pluriwave/servicios/servicio_alarmas.dart'; import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes.dart'; import '../helpers/fakes_alarmas.dart'; class _Entorno { _Entorno({ required this.estadoAlarmas, required this.android, required this.audio, }); final EstadoAlarmas estadoAlarmas; final FakePuertoAlarmasAndroid android; final FakeServicioAudio audio; } Future<_Entorno> _montarPantalla( WidgetTester tester, { int snoozeMinutos = 5, int fadeInSegundos = 0, }) async { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final audio = FakeServicioAudio(); audio.emitirEstado(EstadoReproduccion.reproduciendo); final radio = EstadoRadio( esPremium: () => true, audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadioInactiva(), iniciarAutomaticamente: false, ); addTearDown(radio.dispose); final android = FakePuertoAlarmasAndroid(); // Mutable clock: the alarm is saved at 7:00 and the screen mounts at ring // time (7:30:10), so snooze anchors to the RINGING occurrence — the only // scenario the ringing screen can exist in. var ahora = DateTime(2026, 6, 11, 7, 0); final estadoAlarmas = EstadoAlarmas( esPremium: () => true, servicio: ServicioAlarmas(reloj: () => ahora), android: android, iniciarAutomaticamente: false, ); addTearDown(estadoAlarmas.dispose); addTearDown(android.dispose); await estadoAlarmas.guardarAlarma( AlarmaMusical( id: 'ring1', nombre: 'Despertar', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: const [], snoozeMinutos: snoozeMinutos, fadeInSegundos: fadeInSegundos, emisora: const Emisora( uuid: 'e1', nombre: 'Radio Uno', url: 'https://radio.example/stream', ), ), ); // The screen mounts at ring time: 10s after the 7:30 occurrence fired. ahora = DateTime(2026, 6, 11, 7, 30, 10); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: radio), ChangeNotifierProvider.value(value: estadoAlarmas), ], child: MaterialApp( locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, // A real Scaffold (not a bare SizedBox) is required: the // ScaffoldMessenger only displays a SnackBar through a currently // registered ScaffoldState, and the ringing screen's own Scaffold // pops off the tree by the time the SS-3a force-stop SnackBar shows // (mirrors pantalla_alarma_sonando_dismiss_guard_test.dart). home: const Scaffold(body: SizedBox.shrink()), ), ), ); final navigator = tester.state(find.byType(Navigator)); unawaited( navigator.push( MaterialPageRoute( builder: (_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single), fullscreenDialog: true, ), ), ); await tester.pumpAndSettle(); return _Entorno(estadoAlarmas: estadoAlarmas, android: android, audio: audio); } void main() { final l10n = lookupAppLocalizations(const Locale('es')); setUp(() { SharedPreferences.setMockInitialValues({}); }); group('WU11 — 3 tiles de posponer fijos (3/5/10 min)', () { testWidgets( 'siempre son exactamente 3 tiles fijos, incluso con un snoozeMinutos ' 'personalizado que no es 3/5/10 (S2-R1-A/C, restilizado)', (tester) async { // WU11 correction: the ringing screen's snooze row is no longer a // variable-length Wrap that grows for a custom value — the mockup's // "3 fixed tiles" replaces it. A custom snoozeMinutos (7 here, same // fixture as before WU11) still configures the ALARM's own default // elsewhere (the editor), but no longer grows a 4th tile on this // screen specifically. await _montarPantalla(tester, snoozeMinutos: 7); expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsNothing); expect(find.text(l10n.stopAlarmAction), findsOneWidget); }, ); testWidgets( 'sigue habiendo exactamente 3 tiles cuando snoozeMinutos coincide con ' 'una opcion fija', (tester) async { await _montarPantalla(tester, snoozeMinutos: 5); expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); }, ); testWidgets( 'el tile que coincide con snoozeMinutos es el destacado (FilledButton); ' 'los otros dos son OutlinedButton', (tester) async { await _montarPantalla(tester, snoozeMinutos: 5); expect( find.ancestor( of: find.text(l10n.alarmSnoozeOptionLabel(5)), matching: find.byType(FilledButton), ), findsOneWidget, ); expect( find.ancestor( of: find.text(l10n.alarmSnoozeOptionLabel(3)), matching: find.byType(OutlinedButton), ), findsOneWidget, ); expect( find.ancestor( of: find.text(l10n.alarmSnoozeOptionLabel(10)), matching: find.byType(OutlinedButton), ), findsOneWidget, ); }, ); testWidgets( 'design decision: cuando snoozeMinutos no es 3/5/10, el destacado ' 'por defecto es 10 (el valor "habitual" del mockup)', (tester) async { await _montarPantalla(tester, snoozeMinutos: 7); expect( find.ancestor( of: find.text(l10n.alarmSnoozeOptionLabel(10)), matching: find.byType(FilledButton), ), findsOneWidget, ); expect( find.ancestor( of: find.text(l10n.alarmSnoozeOptionLabel(3)), matching: find.byType(OutlinedButton), ), findsOneWidget, ); expect( find.ancestor( of: find.text(l10n.alarmSnoozeOptionLabel(5)), matching: find.byType(OutlinedButton), ), findsOneWidget, ); }, ); }); group('WU11 — pildora de Detener a todo lo ancho', () { testWidgets('el boton de Detener ocupa todo el ancho disponible', ( tester, ) async { await _montarPantalla(tester); // The test viewport is 1440 logical px wide (physicalSize / ratio set // in `_montarPantalla`); a normal wrap-content button would be well // under 300px. This is a "clearly full-bleed, not auto-sized" // assertion rather than a pixel-perfect one — the exact horizontal // padding is a cosmetic layout detail, not a contract. final tamano = tester.getSize( find.byKey(const ValueKey('ringing-stop-button')), ); expect(tamano.width, greaterThan(1000)); }); }); group('visual fidelity (audit 9.11, proto t4 line 434): Detener es una ' 'superficie neutra translucida, no cian', () { testWidgets( 'fondo blanco 8%, borde blanco 16%, radio 24 — no colorScheme.primary/' 'radiusLg', (tester) async { await _montarPantalla(tester); final elemento = find.byKey(const ValueKey('ringing-stop-button')); final boton = tester.widget(elemento); final contexto = tester.element(elemento); final primary = Theme.of(contexto).colorScheme.primary; final fondo = boton.style?.backgroundColor?.resolve({}); final borde = boton.style?.side?.resolve({}); final forma = boton.style?.shape?.resolve({}) as RoundedRectangleBorder?; expect( fondo, Colors.white.withValues(alpha: 0.08), reason: 'prototype t4 line 434: rgba(255,255,255,.08) — a neutral ' 'translucent surface', ); expect( fondo, isNot(primary), reason: 'the largest element on a full-screen surface must not stay ' 'the brand cyan colorScheme.primary (PluriWaveTokens.brand)', ); expect( borde?.color, Colors.white.withValues(alpha: 0.16), reason: 'prototype t4 line 434 border: rgba(255,255,255,.16)', ); expect( forma?.borderRadius, BorderRadius.circular(24), reason: 'prototype t4 line 434: radius 24 (was radiusLg — 30)', ); }, ); }); group('visual fidelity (audit 9.3, proto t4 line 415): pildora de ' 'programacion', () { testWidgets('alarma diaria: pildora "Cada manana" con icono alarm y acento ' 'warmCoral', (tester) async { await _montarPantalla(tester); // tipoProgramacion: diaria (fixture) final pildora = find.byKey(const ValueKey('ringing-schedule-pill')); expect(pildora, findsOneWidget); expect( find.descendant( of: pildora, matching: find.text(l10n.alarmScheduleDaily), ), findsOneWidget, reason: 'prototype t4 line 415: "CADA MAÑANA"', ); expect( find.descendant(of: pildora, matching: find.byIcon(Icons.alarm)), findsOneWidget, ); final caja = tester.widget(pildora); final decoracion = caja.decoration as BoxDecoration; const acento = PluriWaveTokens.dark; expect( decoracion.color, acento.warmCoral.withValues(alpha: 0.16), reason: 'prototype t4 line 415: rgba(244,184,96,.16)', ); expect( decoracion.borderRadius, BorderRadius.circular(999), reason: 'prototype t4 line 415: border-radius:999px (pill)', ); expect( (decoracion.border as Border).top.color, acento.warmCoral.withValues(alpha: 0.45), reason: 'prototype t4 line 415: border rgba(244,184,96,.45)', ); }); }); group('visual fidelity (audit 9.7, proto t4 line 423): nombre de emisora ' '20/w800', () { testWidgets( 'el nombre de la emisora es 20px/w800/ls-.3, no cardTitle (14.5/w700)', (tester) async { await _montarPantalla(tester); final texto = tester.widget(find.text('Radio Uno')); expect(texto.style?.fontSize, 20); expect(texto.style?.fontWeight, FontWeight.w800); expect(texto.style?.letterSpacing, -0.3); }, ); }); group('WU11 — estado estatico de subida de volumen (resolucion 4, sin ' 'contador en vivo)', () { testWidgets( 'con fadeInSegundos > 0 muestra la etiqueta estatica, sin sufijo ' 'numerico', (tester) async { await _montarPantalla(tester, fadeInSegundos: 20); final texto = tester.widget( find.descendant( of: find.byKey(const ValueKey('estado-subida-volumen')), matching: find.byType(Text), ), ); // Exact-equality (not `contains`) is what proves there is no // interpolated/changing suffix at all — `alarmVolumeRisingStatus` // carries no ARB placeholder, so this can never silently grow a // live counter later without a deliberate key change. expect(texto.data, l10n.alarmVolumeRisingStatus); }, ); testWidgets( 'con fadeInSegundos == 0 (por defecto) no muestra la etiqueta en ' 'absoluto', (tester) async { await _montarPantalla(tester); expect( find.byKey(const ValueKey('estado-subida-volumen')), findsNothing, ); }, ); }); testWidgets( 'posponer 5 min pospone la alarma y cierra la pantalla (S2-R1-B)', (tester) async { final entorno = await _montarPantalla(tester, snoozeMinutos: 5); await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5))); await tester.pumpAndSettle(); final alarma = entorno.estadoAlarmas.alarmas.single; expect(alarma.snoozeHasta, DateTime(2026, 6, 11, 7, 35)); expect(find.byType(PantallaAlarmaSonando), findsNothing); // posponerAlarma oculta la notificacion nativa (mismo stop path que // el boton de detener) y reprograma con el snooze. expect(entorno.android.ocultadas, contains('ring1')); expect( entorno.android.programadas.last.snoozeHasta, DateTime(2026, 6, 11, 7, 35), ); }, ); group('detener y el force-stop de fallback', () { testWidgets( 'detener fallido NO cierra la pantalla y muestra el banner de forzar ' 'detencion; forzar detencion con exito si la cierra (SS-3a/SS-3b)', (tester) async { final entorno = await _montarPantalla(tester); entorno.android.fallaDetener = true; await tester.tap(find.text(l10n.stopAlarmAction)); await tester.pumpAndSettle(); // Verified stop failure (Finding A): the alarm is still ringing, so // the ringing screen must stay up — dismissing here would hide the // only retry affordance while the native ring keeps sounding. expect(find.byType(PantallaAlarmaSonando), findsOneWidget); expect(find.text(l10n.alarmStopFailedMessage), findsOneWidget); expect(find.text(l10n.alarmForceStopAction), findsOneWidget); // Retry via the banner's own action succeeds this time (SS-3b). entorno.android.fallaDetener = false; await tester.tap(find.text(l10n.alarmForceStopAction)); await tester.pumpAndSettle(); expect(find.byType(PantallaAlarmaSonando), findsNothing); }, ); testWidgets('detener confirmado no muestra el banner de fallo (SS-3c)', ( tester, ) async { final entorno = await _montarPantalla(tester); await tester.tap(find.text(l10n.stopAlarmAction)); await tester.pumpAndSettle(); expect(find.byType(PantallaAlarmaSonando), findsNothing); expect(find.text(l10n.alarmStopFailedMessage), findsNothing); expect(entorno.android.detencionesActivas, isNotEmpty); }); testWidgets( 'forzar detencion: invocacion superpuesta es no-op y tras un fallo ' 'el boton sigue funcionando (RES-2)', (tester) async { final entorno = await _montarPantalla(tester); entorno.android.fallaDetener = true; await tester.tap(find.text(l10n.stopAlarmAction)); await tester.pumpAndSettle(); final llamadasPrevias = entorno.android.detencionesActivas.length; entorno.android.detenerActivoGate = Completer(); await tester.tap(find.text(l10n.alarmForceStopAction)); await tester.pump(); await tester.tap(find.text(l10n.alarmForceStopAction)); await tester.pump(); expect( entorno.android.detencionesActivas.length, llamadasPrevias + 1, reason: 'la segunda invocacion superpuesta debe ser no-op', ); entorno.android.detenerActivoGate!.complete(); await tester.pumpAndSettle(); // Fallo confirmado: el banner sigue y el guard debe haberse // reseteado para permitir un reintento. expect(find.byType(PantallaAlarmaSonando), findsOneWidget); entorno.android.fallaDetener = false; await tester.tap(find.text(l10n.alarmForceStopAction)); await tester.pumpAndSettle(); expect(find.byType(PantallaAlarmaSonando), findsNothing); }, ); }); group('reconciliacion de fin de ring externo (RES-1)', () { testWidgets( 'si la alarma se registra como perdida externamente, la pantalla se ' 'auto-cierra', (tester) async { final entorno = await _montarPantalla(tester); entorno.android.emitirEvento( EventoAlarmaAndroid( alarmaId: 'ring1', titulo: 'Despertar', accion: EventoAlarmaAndroid.accionMissed, ), ); await tester.pumpAndSettle(); expect(find.byType(PantallaAlarmaSonando), findsNothing); }, ); }); group('salidas del ring fuera de los botones', () { testWidgets( 'el boton atras del sistema se comporta como Detener: finaliza la ' 'ejecucion y cierra', (tester) async { final entorno = await _montarPantalla(tester); // Simulate the SYSTEM back (routes through PopScope via the // binding), not a direct Navigator.pop: a plain pop only runs // dispose(), which would leave no alarm UI anywhere to stop the // ring (native audio teardown is driven by finalizarEjecucion, the // same path Detener uses). await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(find.byType(PantallaAlarmaSonando), findsNothing); expect( entorno.android.ocultadas, contains('ring1'), reason: 'atras debe finalizar la ejecucion (mismo camino que Detener)', ); }, ); }); }