import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_busqueda.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_navegacion.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; import 'package:pluriwave/pantallas/pantalla_inicio.dart'; import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes.dart'; void main() { setUp(() { SharedPreferences.setMockInitialValues({}); }); testWidgets( 'PantallaInicio muestra custom, reproducir usa EstadoRadio y favorito usa flujo existente', (tester) async { _setLargeSurfaceSize(tester); final audio = FakeServicioAudio(); final favoritos = FakeServicioFavoritos(); final radio = FakeServicioRadio(); final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); final archivo = await _crearArchivoCustom([custom]); final estado = EstadoRadio( audio: audio, favoritos: favoritos, radio: radio, servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadio(), resolverArchivoCustom: () async => archivo, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpStableFrame(tester); await _scrollUntilText(tester, 'Custom Uno'); expect(find.text('Custom Uno'), findsOneWidget); await tester.ensureVisible(find.text('Custom Uno')); await _pumpStableFrame(tester); await tester.tap(find.text('Custom Uno')); await _pumpStableFrame(tester); expect( audio.emisorasReproducidas.map((e) => e.uuid), contains('custom-1'), ); expect(radio.ultimoUuidClick, 'custom-1'); final tarjetaCustom = find.ancestor( of: find.text('Custom Uno'), matching: find.byType(TarjetaEmisora), ); final botonFavorito = find .descendant(of: tarjetaCustom, matching: find.byType(InkWell)) .last; expect(botonFavorito, findsOneWidget); await tester.ensureVisible(botonFavorito); await _pumpStableFrame(tester); await tester.tap(botonFavorito); await _pumpStableFrame(tester); expect(favoritos.toggleCalls, 1); expect(await favoritos.esFavorito(custom.uuid), isTrue); }, ); testWidgets( 'PantallaInicio permite reintentar manualmente tras fallo inicial agotado', (tester) async { _setLargeSurfaceSize(tester); final radio = FakeServicioRadio( erroresPopularesPorLlamada: [Exception('sin red')], popularesPorLlamada: [ const [], [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], ], tendenciasPorLlamada: [ const [], [emisoraDemo(uuid: 'trend-1', nombre: 'Trend Uno')], ], ); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: radio, servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadio(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpStableFrame(tester); await _scrollUntilText(tester, 'Sin conexión a la API de radio'); expect(find.text('Sin conexión a la API de radio'), findsOneWidget); expect(find.text('Reintentar'), findsOneWidget); await tester.ensureVisible(find.text('Reintentar')); await _pumpStableFrame(tester); await tester.tap(find.text('Reintentar')); await _pumpStableFrame(tester); expect(radio.obtenerPopularesCalls, 2); expect(find.text('Sin conexión a la API de radio'), findsNothing); expect(find.text('API Uno'), findsOneWidget); }, ); testWidgets('PantallaFavoritos muestra custom favorito tras recarga', ( tester, ) async { _setLargeSurfaceSize(tester); final favoritos = FakeServicioFavoritos(); final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); final archivo = await _crearArchivoCustom([custom]); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: favoritos, radio: FakeServicioRadio( populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], ), servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadio(), resolverArchivoCustom: () async => archivo, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpStableFrame(tester); await _scrollUntilText(tester, 'Custom Uno'); await _pumpStableFrame(tester); final tarjetaCustom = find.ancestor( of: find.text('Custom Uno'), matching: find.byType(TarjetaEmisora), ); final botonFavorito = find.descendant(of: tarjetaCustom, matching: find.byType(InkWell)).last; expect(botonFavorito, findsOneWidget); await tester.ensureVisible(botonFavorito); await _pumpStableFrame(tester); await tester.tap(botonFavorito); await _pumpStableFrame(tester); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaFavoritos())), ); await _pumpStableFrame(tester); expect(await favoritos.esFavorito(custom.uuid), isTrue); expect(find.text('Custom Uno'), findsOneWidget); }); testWidgets( 'WU5 ADR-7 anti-cache: the Escuchar hero reflects a station changed ' 'from OUTSIDE the widget tree (e.g. Android Auto / a notification ' 'action), proving it caches nothing of its own', (tester) async { _setLargeSurfaceSize(tester); final audio = FakeServicioAudio(); final estado = EstadoRadio( audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadio(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); final estacionA = emisoraDemo(uuid: 'a', nombre: 'Estacion A'); final estacionB = emisoraDemo(uuid: 'b', nombre: 'Estacion B'); await estado.reproducir(estacionA); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); // Bounded pump, not _pumpStableFrame/pumpAndSettle: a "reproduciendo" // station makes VisualizadorAudio start an indeterminately-repeating // AnimationController (visualizador_audio.dart:77, `_controller.repeat()`) // for its animated-fallback waveform — the same class of hazard as an // indeterminate spinner, just via animation. pumpAndSettle() would // never return while it keeps scheduling frames. await _pumpBounded(tester); expect(find.text('Estacion A'), findsOneWidget); // Mutates the underlying ServicioAudio DIRECTLY, bypassing // EstadoRadio.reproducir() entirely — this is exactly the shape of // navegacion_auto.dart's out-of-band mutation (Android Auto's // playFromMediaId). EstadoRadio's own audio.estadoStream listener // (not this test) is what is expected to pick this up and update // emisoraActual. await audio.reproducir(estacionB); await _pumpBounded(tester); expect(find.text('Estacion B'), findsOneWidget); expect(find.text('Estacion A'), findsNothing); }, ); testWidgets('WU5: "Ver todas" switches to the Favoritos root via ' 'EstadoNavegacionRaiz.irA, without pushing a route', (tester) async { _setLargeSurfaceSize(tester); final favoritos = FakeServicioFavoritos(); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: favoritos, radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadio(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await favoritos.agregar(emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno')); await estado.cargarFavoritos(); final navegacion = EstadoNavegacionRaiz(); final observer = _RecordingNavigatorObserver(); await tester.pumpWidget( _conProviders( estado, _testApp(const PantallaInicio(), observers: [observer]), navegacion: navegacion, ), ); await _pumpStableFrame(tester); final pushesAntesDeTocar = observer.pushCount; await tester.ensureVisible(find.text('Ver todas')); await _pumpStableFrame(tester); await tester.tap(find.text('Ver todas')); await _pumpStableFrame(tester); expect(navegacion.actual, RaizPluriWave.favoritos); expect( observer.pushCount, pushesAntesDeTocar, reason: 'switches tabs — must NOT push a new route', ); }); } /// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and /// the providers only expose the instances (no dispose callbacks). /// [navegacion] defaults to a fresh [EstadoNavegacionRaiz] — harmless to /// include for every test, only exercised by the "Ver todas" scenarios. Widget _conProviders( EstadoRadio estado, Widget child, { EstadoNavegacionRaiz? navegacion, }) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: estado), ListenableProvider.value(value: estado.ecualizador), ListenableProvider.value(value: estado.grabacion), ListenableProvider.value(value: estado.busqueda), ChangeNotifierProvider.value( value: navegacion ?? EstadoNavegacionRaiz(), ), ], child: child, ); } Widget _testApp(Widget body, {List observers = const []}) { return MaterialApp( locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, navigatorObservers: observers, home: Scaffold(body: body), ); } /// Counts route pushes so a test can assert "switched tabs, did not push". class _RecordingNavigatorObserver extends NavigatorObserver { int pushCount = 0; @override void didPush(Route route, Route? previousRoute) { pushCount++; } } class FakeServicioGrabacionRadio extends ServicioGrabacionRadio { final _controller = StreamController.broadcast(); @override EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva(); @override Stream get estadoStream => _controller.stream; @override Future inicializar() async {} @override Future dispose() => _controller.close(); } Future _pumpStableFrame(WidgetTester tester) async { await tester.pump(); await tester.pumpAndSettle(const Duration(milliseconds: 100)); } /// WU5: bounded pump, safe when a "reproduciendo" station is rendered — /// `VisualizadorAudio` starts a repeating `AnimationController` for its /// animated-fallback waveform in that case, which `pumpAndSettle` (used by /// `_pumpStableFrame`) would wait on forever. Future _pumpBounded(WidgetTester tester) async { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); } void _setLargeSurfaceSize(WidgetTester tester) { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); } Future _scrollUntilText(WidgetTester tester, String text) async { await tester.scrollUntilVisible( find.text(text), 300, scrollable: find.byType(Scrollable).first, ); await _pumpStableFrame(tester); } Future _crearArchivoCustom(List emisoras) async { final nombre = emisoras.isEmpty ? 'emisoras_custom_vacio.json' : 'emisoras_custom_uno.json'; return File('${Directory.current.path}/test/fixtures/$nombre'); } Future _archivoCustomVacio() async => _crearArchivoCustom(const []);