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/modelos/emisora.dart'; import 'package:pluriwave/pantallas/pantalla_inicio.dart'; import 'package:pluriwave/widgets/visualizador_audio.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes.dart'; void main() { setUp(() { SharedPreferences.setMockInitialValues({}); }); // WU6 correction: the 3 scenarios that used to live here ("muestra custom // ... favorito usa flujo existente", "permite reintentar manualmente", // "PantallaFavoritos muestra custom favorito tras recarga") all depended // on `_gridEmisoras`/`_errorBanner`, which task 6.5 relocates to // `PantallaBuscar` and DELETES from here (WU5's task 5.9 deferred this // cleanup explicitly). Their coverage moved, adapted, to // `pantalla_buscar_test.dart` — PantallaInicio no longer renders any // station list of its own (only the hero + the "Tus emisoras" favorites // preview), so there is nothing left on this screen for those scenarios // to exercise. 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', ); }); testWidgets( 'visual fidelity (audit 1.10): Tus emisoras is a 2-column grid showing ' 'up to 8 stations, not a 6-capped horizontal strip', (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); for (var i = 0; i < 8; i++) { await favoritos.agregar( emisoraDemo(uuid: 'grid-$i', nombre: 'Grid Estacion $i'), ); } await estado.cargarFavoritos(); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpStableFrame(tester); for (var i = 0; i < 8; i++) { expect( find.text('Grid Estacion $i'), findsOneWidget, reason: 'prototype t4 lines 82-88: a grid of 8, the old strip capped ' 'at 6', ); } final grid = tester.widget(find.byType(GridView)); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect( delegate.crossAxisCount, 2, reason: 'prototype t4 line 82: grid-template-columns:1fr 1fr', ); }, ); testWidgets('visual fidelity (audit 1.3): the hero art is 132 square', ( tester, ) async { // The redesign shipped this at 84 — the prototype draws 132 (t4 line // 56). Nothing caught it because every existing test here asserts // behaviour, never dimensions. This is the guard that would have. _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); final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'); await favoritos.agregar(sonando); await estado.cargarFavoritos(); // The hero only renders when a station is active — `emisoraActual` is // what gates it. Without this the assertion below passes vacuously. await tester.runAsync(() => estado.reproducir(sonando)); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpBounded(tester); final cuadrados = tester .widgetList(find.byType(SizedBox)) .where((s) => s.width == 132 && s.height == 132); expect( cuadrados, isNotEmpty, reason: 'prototype t4 line 56 sizes the Escuchar hero artwork at 132', ); }); testWidgets('visual fidelity (audit 1.6): the hero shows "genre · country · ' 'bitrate kbps" built from the station model, nothing invented', ( 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); const completa = Emisora( uuid: 'meta-1', nombre: 'Radio Horizonte', url: 'https://stream.demo/radio', tags: 'Noticias', pais: 'España', bitrate: 128, ); await favoritos.agregar(completa); await estado.cargarFavoritos(); // The hero only renders when a station is active. await tester.runAsync(() => estado.reproducir(completa)); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpBounded(tester); expect( find.text('Noticias · España · 128 kbps'), findsOneWidget, reason: 'prototype t4 line 62: "Noticias · España · 128 kbps"', ); }); testWidgets( 'visual fidelity (audit 1.7): the hero visualizer renders 30 discrete ' 'bars, not the continuous waveform stroke', (tester) async { // Item 16 is exactly the widget the audit warned about // (visualizador_audio.dart:77, `_controller.repeat()`) — bounded pump // only, never pumpAndSettle. _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); final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'); await favoritos.agregar(sonando); await estado.cargarFavoritos(); await tester.runAsync(() => estado.reproducir(sonando)); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpBounded(tester); expect( find.byKey(const Key('visualizador-barra-0')), findsOneWidget, reason: 'prototype t4 line 66-68: 30 discrete bottom-anchored bars', ); expect( find.descendant( of: find.byType(VisualizadorAudio), matching: find.byType(CustomPaint), ), findsNothing, ); }, ); testWidgets( 'visual fidelity (audit 1.6): a station missing every meta field omits ' 'the line gracefully, no stray separators', (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); final desnuda = emisoraDemo(uuid: 'meta-2', nombre: 'Radio Desnuda'); await favoritos.agregar(desnuda); await estado.cargarFavoritos(); await tester.runAsync(() => estado.reproducir(desnuda)); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpBounded(tester); expect(find.byKey(const ValueKey('escuchar-hero-meta')), findsNothing); }, ); testWidgets('visual fidelity (audit 1.11): a count badge sits next to "Tus ' 'emisoras", showing the TOTAL favorite count, not the 8-capped grid ' 'size', (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); for (var i = 0; i < 11; i++) { await favoritos.agregar( emisoraDemo(uuid: 'badge-$i', nombre: 'Badge Estacion $i'), ); } await estado.cargarFavoritos(); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpStableFrame(tester); expect( find.text('11'), findsOneWidget, reason: 'prototype t4 line 80: the badge reflects how many favorites ' 'exist, even though the grid below caps display at 8', ); }); testWidgets( 'visual fidelity (audit 1.12): "Ver todas" renders as plain brand-teal ' 'text, not a Material TextButton', (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(); await tester.pumpWidget( _conProviders(estado, _testApp(const PantallaInicio())), ); await _pumpStableFrame(tester); expect(find.byType(TextButton), findsNothing); final texto = tester.widget(find.text('Ver todas')); expect(texto.style?.fontSize, 12); expect(texto.style?.fontWeight, FontWeight.w800); expect(texto.style?.color, const Color(0xFF21D4D9)); }, ); } /// 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++; } } 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 _archivoCustomVacio() async => File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json');