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_audio.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, // Slice 3 (fade-in dedup): existing callers rely on the radio already // being "reproduciendo" by mount time, which cancels the fallback timer // synchronously and leaves nothing to observe mid-handoff. Fade-in-gate // tests need a live `_estadoSub` subscriber instead, so they set this to // false and emit `reproduciendo` themselves after the widget mounts. bool audioYaReproduciendo = true, }) async { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final audio = FakeServicioAudio(); if (audioYaReproduciendo) { audio.emitirEstado(EstadoReproduccion.reproduciendo); } final radio = EstadoRadio( audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadioInactiva(), iniciarAutomaticamente: false, ); addTearDown(radio.dispose); final android = FakePuertoAlarmasAndroid(); final ahora = DateTime(2026, 6, 11, 7, 0); final estadoAlarmas = EstadoAlarmas( 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', ), ), ); 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, home: const SizedBox.shrink(), ), ), ); final navigator = tester.state(find.byType(Navigator)); unawaited( navigator.push( MaterialPageRoute( builder: (_) => PantallaAlarmaSonando( alarma: estadoAlarmas.alarmas.single, audioPrearrancado: true, ), fullscreenDialog: true, ), ), ); await tester.pumpAndSettle(); return _Entorno(estadoAlarmas: estadoAlarmas, android: android, audio: audio); } void main() { final l10n = lookupAppLocalizations(const Locale('es')); setUp(() { SharedPreferences.setMockInitialValues({}); }); testWidgets( 'muestra botones de posponer 3/5/10 mas el personalizado (S2-R1-A/C)', (tester) async { await _montarPantalla(tester, snoozeMinutos: 7); expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsOneWidget); expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); expect(find.text(l10n.stopAlarmAction), findsOneWidget); }, ); testWidgets( 'no duplica el boton 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( 'posponer 5 min detiene el audio local, pospone y cierra (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(entorno.audio.pausas, greaterThanOrEqualTo(1)); 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('rampa anclada y override diferido al primer audio', () { testWidgets('la rampa arranca al montar sin esperar al stream, y el ' 'override del volumen del dispositivo espera a "reproduciendo"', (tester) async { final entorno = await _montarPantalla( tester, audioYaReproduciendo: false, ); // La rampa esta anclada al inicio de la alarma: con fade 0 salta ya al // objetivo (1.0) aunque el stream siga bufferizando. Lo que NO debe // haber ocurrido todavia es el override del stream del dispositivo // (el spin-up del reproductor debe pasar bajo el volumen original) ni // el stop nativo del handoff. expect(entorno.audio.volumenesAplicados, [0.05, 1.0]); expect( entorno.android.volumenForzado, isEmpty, reason: 'el override debe esperar a que el audio realmente fluya', ); expect(entorno.android.detenidas, isEmpty); // La radio llega a "reproduciendo": recien ahi se sube el stream al // nivel configurado y se confirma el handoff (stop nativo). entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo); await tester.pumpAndSettle(); expect(entorno.android.volumenForzado, [0.85]); expect(entorno.android.detenidas, contains('ring1')); }); testWidgets('si confirmar el audio con el nativo falla, el fade-in de Dart ' 'arranca igual (el nativo esta muerto o nunca corrio)', (tester) async { final entorno = await _montarPantalla( tester, audioYaReproduciendo: false, ); entorno.android.fallaConfirmarAudioFlutter = true; entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo); await tester.pumpAndSettle(); // La confirmacion nativa fallo (el fake lanza antes de registrar en // `detenidas`), pero el fade-in de Dart debe arrancar de todas // formas: si el lado nativo esta muerto o nunca corrio, Dart es la // unica fuente audible, y el ring no debe quedar pegado en // _volumenInicialFadeIn para siempre. expect(entorno.android.detenidas, isEmpty); expect(entorno.audio.volumenesAplicados, [0.05, 1.0]); }); }); group('handoff con audio prearrancado ya reproduciendo (regresion)', () { testWidgets('si la radio ya esta reproduciendo al montar, la confirmacion ' 'del handoff y el fade-in arrancan igual', (tester) async { // Real production path: app.dart pre-starts the station BEFORE the // screen mounts, so `reproduciendo` is emitted before the state // listener subscribes and no further state event ever arrives. The // handoff confirmation (native stop) and the Dart ramp must not // depend on catching that already-missed event. final entorno = await _montarPantalla(tester); expect( entorno.android.detenidas, contains('ring1'), reason: 'el nativo debe recibir el stop del handoff aunque ' '"reproduciendo" haya llegado antes del mount', ); expect( entorno.audio.volumenesAplicados, [0.05, 1.0], reason: 'el fade-in debe arrancar tambien en el camino ya-sonando', ); }); }); group('salidas del ring fuera de los botones', () { testWidgets('el boton atras del sistema se comporta como Detener: para la ' 'radio, finaliza 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 // leaves the shared radio player ringing with no alarm UI left to // stop it. await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(find.byType(PantallaAlarmaSonando), findsNothing); expect( entorno.audio.pausas, greaterThanOrEqualTo(1), reason: 'atras debe pausar la radio como lo hace Detener', ); expect( entorno.android.ocultadas, contains('ring1'), reason: 'atras debe finalizar la ejecucion (mismo camino que Detener)', ); }); testWidgets('salir a mitad de rampa restaura la ganancia del reproductor ' 'a 1.0 para la radio normal', (tester) async { final entorno = await _montarPantalla(tester, fadeInSegundos: 30); // Let the ramp advance a few steps (250ms per step) so the shared // handler gain sits at a partial value well below 1.0. await tester.pump(const Duration(seconds: 2)); expect(entorno.audio.volumenesAplicados.last, lessThan(0.2)); await tester.tap(find.text(l10n.stopAlarmAction)); await tester.pumpAndSettle(); expect( entorno.audio.volumenesAplicados.last, 1.0, reason: 'la salida del ring no debe dejar la radio normal al nivel ' 'parcial de la rampa', ); }); }); group('restore de volumen de medios con dispose como unico llamador', () { testWidgets('desmontar la pantalla sin detener ni posponer restaura el ' 'volumen exactamente una vez', (tester) async { final entorno = await _montarPantalla(tester); // Teardown that bypasses _detener()/_posponer() entirely: dispose() // must work as a restore path on its own. Reading the BuildContext // inside dispose() throws (the element is already defunct), so the // port reference has to be captured while the widget is mounted. await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpAndSettle(); expect(entorno.android.volumenRestaurado, 1); }); }); }