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_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/pais_radio.dart'; import 'package:pluriwave/pantallas/pantalla_buscar.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; import 'package:pluriwave/pantallas/pantalla_paises.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/tema/pluriwave_theme.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/widgets/fila_emisora_plana.dart'; import 'package:pluriwave/widgets/pluri_layout.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes.dart'; /// WU6, `station-discovery-browse` spec: Buscar's landing state (relocated /// discovery content from PantallaInicio, task 6.5), active-filter pills + /// results counter + clear-all-filters (task 6.6), and the client-side /// "Ordenar" control (task 6.7). void main() { setUp(() { SharedPreferences.setMockInitialValues({}); }); group('Buscar — landing state (empty query shows discovery content)', () { testWidgets('consulta vacia muestra cerca de vos, generos y tendencias; ' 'el grid de resultados de busqueda no aparece', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( populares: [emisoraDemo(uuid: 'pop-1', nombre: 'Populares Uno')], tendencias: [emisoraDemo(uuid: 'tr-1', nombre: 'Tendencia Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); expect(find.text(l10n.nearYou), findsOneWidget); // Audit 3.2 (t4 lines 164-170): the "Explorar por" 2x2 grid replaced // the old always-visible Tendencias chip strip and Géneros chip Wrap // (both now live behind this grid's own cells, task 18's scope). expect(find.text(l10n.exploreByTitle), findsOneWidget); expect(find.text(l10n.countriesScreenTitle), findsOneWidget); expect(find.text(l10n.genresTitle), findsOneWidget); expect(find.text(l10n.exploreTrendingTitle), findsOneWidget); expect(find.text(l10n.exploreNewTitle), findsOneWidget); expect(find.text('Populares Uno'), findsOneWidget); // The search-results empty state must NOT show — this is the // landing state, not "you searched and got nothing". expect(find.text(l10n.searchEmptyTitle), findsNothing); }); testWidgets( 'escribir una consulta reemplaza el contenido de descubrimiento por ' 'la vista de resultados', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( populares: [emisoraDemo(uuid: 'pop-1', nombre: 'Populares Uno')], busqueda: [emisoraDemo(uuid: 'res-1', nombre: 'Resultado Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); expect(find.text(l10n.nearYou), findsOneWidget); await tester.enterText(find.byType(SearchBar), 'resultado'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); expect(find.text(l10n.nearYou), findsNothing); expect(find.text('Resultado Uno'), findsOneWidget); }, ); }); group('Buscar — active-filter pills y contador de resultados', () { testWidgets( 'aplicar un filtro de pais muestra un pill removible y el contador ' 'refleja el conteo filtrado', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); await _abrirYSeleccionarPais(tester, l10n.countrySpain); await _pumpStableFrame(tester); expect(find.text(l10n.countrySpain), findsOneWidget); expect(find.byIcon(Icons.close), findsWidgets); expect(find.text(l10n.searchResultsCount(1)), findsOneWidget); }, ); testWidgets('quitar un pill vuelve a buscar sin ese filtro', ( tester, ) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); await _abrirYSeleccionarPais(tester, l10n.countrySpain); await _pumpStableFrame(tester); expect(find.text(l10n.countrySpain), findsOneWidget); final pillPais = find.ancestor( of: find.text(l10n.countrySpain), matching: find.byType(Chip), ); await tester.tap( find.descendant(of: pillPais, matching: find.byIcon(Icons.close)), ); await _pumpStableFrame(tester); expect(find.text(l10n.countrySpain), findsNothing); }); testWidgets( 'dos filtros activos y cero resultados ofrecen quitar ambos de una ' 'sola vez', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado(radio: FakeServicioRadio(busqueda: [])); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); await _abrirYSeleccionarPais(tester, l10n.countrySpain); await _pumpStableFrame(tester); await _abrirYSeleccionarCalidad(tester, '320 kbps'); await _pumpStableFrame(tester); expect(find.text(l10n.countrySpain), findsOneWidget); final accionQuitar = find.text(l10n.searchClearFiltersAction(2)); expect(accionQuitar, findsOneWidget); await tester.tap(accionQuitar); await _pumpStableFrame(tester); expect(find.text(l10n.countrySpain), findsNothing); }, ); }); group('visual fidelity (audit 6.2/6.3/6.4)', () { testWidgets( '6.2: an active filter pill is brand-teal tinted with an inline ' 'close glyph (t4:292-293)', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); await _abrirYSeleccionarPais(tester, l10n.countrySpain); await _pumpStableFrame(tester); final chip = tester.widget( find.ancestor( of: find.text(l10n.countrySpain), matching: find.byType(Chip), ), ); expect( chip.backgroundColor, const Color(0xFF21D4D9).withValues(alpha: 0.2), ); expect( (chip.shape as RoundedRectangleBorder?)?.side.color, const Color(0xFF21D4D9).withValues(alpha: 0.45), ); }, ); testWidgets( '6.3: an "Idioma" entry chip is always reachable once a search is ' 'active, opening the filters sheet (t4:294-295)', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'radio'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); expect(find.text(l10n.searchLanguageFilterLabel), findsOneWidget); await tester.tap(find.text(l10n.searchLanguageFilterLabel)); await tester.pumpAndSettle(); expect(find.text(l10n.searchCountryFilterLabel), findsOneWidget); }, ); testWidgets( '6.4: the results-count eyebrow uses eyebrowLabel styling (t4:299)', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'radio'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); final texto = tester.widget( find.text(l10n.searchResultsCount(1)), ); expect(texto.style?.fontSize, 11); expect(texto.style?.fontWeight, FontWeight.w800); }, ); }); group('Buscar — Ordenar (client-side, WU6)', () { testWidgets( 'cada opcion renderizada de Ordenar corresponde a un caso real de ' 'OrdenEmisoras (regresion: nunca una opcion decorativa)', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'radio'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); await tester.tap(find.byIcon(Icons.swap_vert_rounded)); await tester.pumpAndSettle(); final l10n = _l10nDe(tester); expect( find.byType(PopupMenuItem), findsNWidgets(OrdenEmisoras.values.length), ); expect(find.text(l10n.stationOrderByName), findsOneWidget); expect(find.text(l10n.stationOrderByQuality), findsOneWidget); expect(find.text(l10n.stationOrderByPopularity), findsOneWidget); }, ); testWidgets('elegir un criterio reordena la pagina actual sin una nueva ' 'solicitud de red con parametro order', (tester) async { _setLargeSurfaceSize(tester); final radio = FakeServicioRadio( busqueda: [ emisoraDemo( uuid: 'alta', nombre: 'Estacion Alta', ).copyWith(bitrate: 320, votes: 1), emisoraDemo( uuid: 'popular', nombre: 'Estacion Popular', ).copyWith(bitrate: 64, votes: 900), ], ); final estado = _crearEstado(radio: radio); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'estacion'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); // Default global ordering is OrdenEmisoras.calidad (bitrate desc): // "Estacion Alta" (320 kbps) must render above "Estacion Popular". final yAltaAntes = tester.getTopLeft(find.text('Estacion Alta')).dy; final yPopularAntes = tester.getTopLeft(find.text('Estacion Popular')).dy; expect(yAltaAntes, lessThan(yPopularAntes)); final llamadasAntes = radio.buscarCalls; final l10n = _l10nDe(tester); await tester.tap(find.byIcon(Icons.swap_vert_rounded)); await tester.pumpAndSettle(); await tester.tap(find.text(l10n.stationOrderByPopularity)); await _pumpStableFrame(tester); expect( radio.buscarCalls, llamadasAntes, reason: 'sorting must not trigger a new /json request', ); final yAltaDespues = tester.getTopLeft(find.text('Estacion Alta')).dy; final yPopularDespues = tester.getTopLeft(find.text('Estacion Popular')).dy; expect(yPopularDespues, lessThan(yAltaDespues)); }); }); group('Buscar — contenido relocalizado desde PantallaInicio (WU6 6.5)', () { testWidgets('el grid de descubrimiento muestra custom + populares; tocar ' 'reproduce via EstadoRadio y el boton de favorito usa el 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 = _crearEstado( audio: audio, favoritos: favoritos, radio: radio, resolverArchivoCustom: () async => archivo, ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); expect(find.text('Custom Uno'), findsOneWidget); 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('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 = _crearEstado(radio: radio); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); expect(find.text('Sin conexión a la API de radio'), findsOneWidget); final l10n = _l10nDe(tester); expect(find.text(l10n.retryAction), findsOneWidget); await tester.tap(find.text(l10n.retryAction)); 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( 'visual fidelity (audit 13.1, proto t4 line 641): banner sin conexion ' 'usa offlineAccent, no colorScheme.error', (tester) async { _setLargeSurfaceSize(tester); final radio = FakeServicioRadio( erroresPopularesPorLlamada: [Exception('sin red')], ); final estado = _crearEstado(radio: radio); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); final banner = find.byKey(const ValueKey('offline-banner')); expect(banner, findsOneWidget); final contexto = tester.element(banner); final tokens = contexto.pluriTokens; final errorColor = Theme.of(contexto).colorScheme.error; final caja = tester.widget(banner); final decoracion = caja.decoration as BoxDecoration; expect( decoracion.color, tokens.offlineAccent.withValues(alpha: 0.14), reason: 'prototype t4 line 641: rgba(207,102,121,.14) — this app ' 'consolidates on the offlineAccent token (#E8879A)', ); expect(decoracion.color, isNot(errorColor)); expect( decoracion.borderRadius, BorderRadius.circular(16), reason: 'prototype t4 line 641: border-radius:16px', ); final icono = tester.widget( find.descendant(of: banner, matching: find.byIcon(Icons.wifi_off)), ); expect(icono.color, tokens.offlineAccent); expect(icono.color, isNot(errorColor)); final l10n = _l10nDe(tester); expect(find.text(l10n.offlineBannerTitle), findsOneWidget); expect(find.text(l10n.retryAction), findsOneWidget); }, ); testWidgets( 'PantallaFavoritos muestra el custom marcado como favorito desde ' 'Buscar tras recargar', (tester) async { _setLargeSurfaceSize(tester); final favoritos = FakeServicioFavoritos(); final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); final archivo = await _crearArchivoCustom([custom]); final estado = _crearEstado( favoritos: favoritos, radio: FakeServicioRadio( populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], ), resolverArchivoCustom: () async => archivo, ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); 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; await tester.ensureVisible(botonFavorito); await _pumpStableFrame(tester); await tester.tap(botonFavorito); await _pumpStableFrame(tester); await tester.pumpWidget( _conProviders(estado, _testApp(child: const PantallaFavoritos())), ); await _pumpStableFrame(tester); expect(await favoritos.esFavorito(custom.uuid), isTrue); expect(find.text('Custom Uno'), findsOneWidget); }, ); }); group('Buscar — "Explorar por" 2x2 grid (audit 3.2)', () { testWidgets('renders exactly 4 cells arranged 2 per row', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado(); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); expect(find.byKey(const Key('explore-cell-paises')), findsOneWidget); expect(find.byKey(const Key('explore-cell-generos')), findsOneWidget); expect(find.byKey(const Key('explore-cell-tendencias')), findsOneWidget); expect(find.byKey(const Key('explore-cell-novedades')), findsOneWidget); // Geometry, not GridView internals: prototype t4 line 166 // (grid-template-columns:1fr 1fr) means Países/Géneros share a row // and Tendencias/Novedades share the NEXT row, 2 columns each. final paises = tester.getTopLeft( find.byKey(const Key('explore-cell-paises')), ); final generos = tester.getTopLeft( find.byKey(const Key('explore-cell-generos')), ); final tendencias = tester.getTopLeft( find.byKey(const Key('explore-cell-tendencias')), ); final novedades = tester.getTopLeft( find.byKey(const Key('explore-cell-novedades')), ); expect(paises.dy, generos.dy, reason: 'Países/Géneros share a row'); expect( tendencias.dy, novedades.dy, reason: 'Tendencias/Novedades share a row', ); expect( paises.dy, lessThan(tendencias.dy), reason: 'the first row sits above the second', ); expect(paises.dx, lessThan(generos.dx), reason: 'Países is column 1'); expect( tendencias.dx, lessThan(novedades.dx), reason: 'Tendencias is column 1', ); }); testWidgets( 'each cell icon uses the prototype accent colour (t4 lines 167-170)', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado(); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); Color colorDe(String key) { final icono = tester.widget( find.descendant( of: find.byKey(Key(key)), matching: find.byType(Icon), ), ); return icono.color!; } final contexto = tester.element( find.byKey(const Key('explore-cell-generos')), ); expect(colorDe('explore-cell-paises'), PluriWaveTokens.brand); expect(colorDe('explore-cell-generos'), contexto.pluriTokens.liveGreen); expect( colorDe('explore-cell-tendencias'), contexto.pluriTokens.warmCoral, ); expect(colorDe('explore-cell-novedades'), PluriWaveTokens.skyBlue); }, ); testWidgets('tapping Paises pushes PantallaPaises', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado(); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.tap(find.byKey(const Key('explore-cell-paises'))); await _pumpStableFrame(tester); expect(find.byType(PantallaPaises), findsOneWidget); }); testWidgets( 'Item 24: tapping a country row in Paises pops back and filters ' 'Buscar by that ISO code', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( paises: const [ PaisRadio( nombre: 'Kazakhstan', codigoIso: 'KZ', numeroEmisoras: 12, ), ], busqueda: [emisoraDemo(uuid: 'kz-1', nombre: 'Radio Kazajstan')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.tap(find.byKey(const Key('explore-cell-paises'))); await _pumpStableFrame(tester); expect(find.byType(PantallaPaises), findsOneWidget); await tester.tap(find.text('Kazakhstan')); await _pumpStableFrame(tester); expect( find.byType(PantallaPaises), findsNothing, reason: 'selecting a country pops back to Buscar', ); expect(find.text('Radio Kazajstan'), findsOneWidget); expect(estado.busqueda.resultados.map((e) => e.uuid), contains('kz-1')); }, ); testWidgets( 'tapping Generos opens a picker sheet; selecting a genre closes it ' 'and filters the discovery grid (same capability, relocated)', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'rock-1', nombre: 'Rock Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.tap(find.byKey(const Key('explore-cell-generos'))); await tester.pumpAndSettle(); final l10n = _l10nDe(tester); await tester.tap(find.text(l10n.genreRock)); await tester.pumpAndSettle(); // Tap-once picker (matches this file's country/language/quality // filter sheets) — the sheet closes and the grid below now shows // the genre-filtered results. expect(find.byType(BottomSheet), findsNothing); expect(find.text('Rock Uno'), findsOneWidget); }, ); testWidgets('tapping Tendencias opens a sheet listing the trending strip; ' 'tapping a station plays it via the existing flow', (tester) async { _setLargeSurfaceSize(tester); final audio = FakeServicioAudio(); final estado = _crearEstado( audio: audio, radio: FakeServicioRadio( tendencias: [emisoraDemo(uuid: 'trend-1', nombre: 'Trend Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.tap(find.byKey(const Key('explore-cell-tendencias'))); await tester.pumpAndSettle(); await tester.tap(find.text('Trend Uno')); await _pumpStableFrame(tester); expect( audio.emisorasReproducidas.map((e) => e.uuid), contains('trend-1'), ); }); testWidgets( 'tapping Novedades re-triggers the discovery refresh (no distinct ' '"new stations" feed exists in the domain)', (tester) async { _setLargeSurfaceSize(tester); final radio = FakeServicioRadio(); final estado = _crearEstado(radio: radio); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); final llamadasAntes = radio.obtenerPopularesCalls; await tester.tap(find.byKey(const Key('explore-cell-novedades'))); await _pumpStableFrame(tester); expect(radio.obtenerPopularesCalls, greaterThan(llamadasAntes)); }, ); }); // Item 23 / audit 6.5 + 6.6 (t4:302-306): flat, background-less search- // result rows with a square thumbnail, a favourite toggle, and a circular // play affordance -- replacing the full glass TarjetaEmisora card. Rows // sit back-to-back (no 10px separator), matching t4:299's bare column. group('Item 23 -- flat search-result rows (audit 6.5, 6.6)', () { testWidgets( 'each result is a flat FilaEmisoraPlana with a "genre - country - ' 'kbps" meta line, not the full glass card', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [ emisoraDemo( uuid: 'r-1', nombre: 'Radio Uno', ).copyWith(tags: 'Rock', pais: 'Spain', bitrate: 128), ], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'radio'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); expect(find.byType(FilaEmisoraPlana), findsOneWidget); expect( find.byType(TarjetaEmisora), findsNothing, reason: 'audit 6.5 replaces the full glass card with a flat row', ); expect(find.text('Rock · Spain · 128 kbps'), findsOneWidget); expect(find.byType(BotonFavoritoEmisora), findsOneWidget); expect(find.byType(BotonReproducirCircular), findsOneWidget); }, ); testWidgets('rows sit back-to-back, with no 10px separator (t4:299)', ( tester, ) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [ emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno'), emisoraDemo(uuid: 'r-2', nombre: 'Radio Dos'), ], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'radio'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); final filas = find.byType(FilaEmisoraPlana); expect(filas, findsNWidgets(2)); final primeraAbajo = tester.getBottomLeft(filas.at(0)).dy; final segundaArriba = tester.getTopLeft(filas.at(1)).dy; expect( segundaArriba - primeraAbajo, closeTo(0, 0.5), reason: 't4:299 rows have no separator; each keeps only its own ' '8px padding', ); }); testWidgets('issue 3 (feedback-pruebas): the results list uses row-tier ' 'horizontal padding (12), not the card-tier constant this "flat, ' 'background-less row" was documented as needing but never got', ( tester, ) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'radio'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); final fila = find.byType(FilaEmisoraPlana); expect( tester.getTopLeft(fila).dx, PluriLayout.rowHorizontal, reason: 'issue 3: background-less rows are row tier (12), not card ' 'tier (16)', ); }); testWidgets('issue 3 (feedback-pruebas): the results list is topped by the ' 'standard section gap, not the horizontal-inset constant reused for ' 'a vertical axis', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'radio'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); // Reads the structural padding directly, rather than measuring a // gap between two rendered widgets — the count row's own height is // dictated by its taller PopupMenuButton (48dp touch target), so a // position-based gap measurement against the count TEXT specifically // would be thrown off by that unrelated vertical centring. // // Scoped to `shrinkWrap: true` — the OUTER page ListView is ALSO an // ancestor of every `FilaEmisoraPlana`, but only `_resultados`'s OWN // inner `ListView.builder` sets `shrinkWrap`. final listaResultados = tester.widget( find.byWidgetPredicate((w) => w is ListView && w.shrinkWrap), ); final padding = listaResultados.padding as EdgeInsets; expect( padding.top, PluriLayout.sectionGap, reason: 'issue 3: the results list must use the dedicated vertical ' 'section gap above its first row, not the horizontal (16) ' 'constant reused for a vertical axis', ); }); testWidgets( 'tapping the favourite toggle on a search result adds it to favorites', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado( radio: FakeServicioRadio( busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')], ), ); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'radio'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget); await tester.tap(find.byIcon(Icons.favorite_outline_rounded)); await _pumpStableFrame(tester); expect(await estado.esFavorito('r-1'), isTrue); }, ); }); // Item 25 / audit 13.2 (t4:643-646): a reconnect card with a rotating // ring, the station name, and a stop affordance -- previously the ONLY // signal of a reconnect was a word in the mini player. // // Hazard: `pumpAndSettle()` never terminates while this card's rotating // ring animates -- every assertion below uses a bounded `pump()` once // `reconectando` is emitted, never `_pumpStableFrame`'s `pumpAndSettle`. group('visual fidelity (audit 13.5/13.6)', () { testWidgets( '13.5/13.6: the no-results card quotes the query, and the "clear ' 'filters" pill sits INSIDE the same card (t4:657-663)', (tester) async { _setLargeSurfaceSize(tester); final estado = _crearEstado(radio: FakeServicioRadio(busqueda: [])); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); await _abrirYSeleccionarPais(tester, _l10nDe(tester).countrySpain); await _pumpStableFrame(tester); await tester.enterText(find.byType(SearchBar), 'jazzz'); await tester.testTextInput.receiveAction(TextInputAction.done); await _pumpStableFrame(tester); final l10n = _l10nDe(tester); expect( find.text(l10n.searchNoResultsForQueryTitle('jazzz')), findsOneWidget, ); final tarjeta = find.byKey(const ValueKey('search-no-results-card')); expect(tarjeta, findsOneWidget); expect( find.descendant( of: tarjeta, matching: find.text(l10n.searchClearFiltersAction(1)), ), findsOneWidget, reason: 't4:667 the clear-filters pill sits INSIDE the card', ); }, ); }); group('Item 25 -- reconnect card (audit 13.2)', () { testWidgets( 'shows the station name, "Reconectando...", and a stop button while ' 'reconnecting', (tester) async { _setLargeSurfaceSize(tester); final audio = FakeServicioAudio(); final estado = _crearEstado(audio: audio); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno')); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); expect( find.byIcon(Icons.stop_circle_rounded), findsNothing, reason: 'not reconnecting yet -- the card must not show', ); audio.emitirEstado(EstadoReproduccion.reconectando); await tester.pump(); await tester.pump(); expect(find.text('Radio Uno'), findsOneWidget); final l10n = _l10nDe(tester); expect(find.text(l10n.playbackStatusReconnecting), findsOneWidget); expect(find.byIcon(Icons.stop_circle_rounded), findsOneWidget); }, ); testWidgets('tapping stop calls EstadoRadio.detenerReproduccion', ( tester, ) async { _setLargeSurfaceSize(tester); final audio = FakeServicioAudio(); final estado = _crearEstado(audio: audio); addTearDown(estado.dispose); await tester.runAsync(estado.inicializar); await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno')); await tester.pumpWidget(_conProviders(estado, _testApp())); await _pumpStableFrame(tester); audio.emitirEstado(EstadoReproduccion.reconectando); await tester.pump(); await tester.pump(); await tester.tap(find.byIcon(Icons.stop_circle_rounded)); await tester.pump(); await tester.pump(); // `EstadoRadio.emisoraActual` deliberately keeps showing the last // selected station even once stopped (an existing, unrelated // property of `_emisoraSeleccionada`'s tracking) -- the real, // user-visible effect of tapping stop is that this card disappears, // since it only renders while `reconectando`. expect(find.byKey(const ValueKey('tarjeta-reconectando')), findsNothing); }); }); } EstadoRadio _crearEstado({ FakeServicioAudio? audio, FakeServicioFavoritos? favoritos, FakeServicioRadio? radio, Future Function()? resolverArchivoCustom, }) { return EstadoRadio( audio: audio ?? FakeServicioAudio(), favoritos: favoritos ?? FakeServicioFavoritos(), radio: radio ?? FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadio(), resolverArchivoCustom: resolverArchivoCustom ?? _archivoCustomVacio, iniciarAutomaticamente: false, ); } Widget _conProviders(EstadoRadio estado, Widget child) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: estado), ListenableProvider.value(value: estado.ecualizador), ListenableProvider.value(value: estado.grabacion), ListenableProvider.value(value: estado.busqueda), ], child: child, ); } Widget _testApp({Widget child = const PantallaBuscar()}) { return MaterialApp( locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: child), ); } AppLocalizations _l10nDe(WidgetTester tester) { return AppLocalizations.of(tester.element(find.byType(PantallaBuscar))); } Future _abrirYSeleccionarPais(WidgetTester tester, String label) async { final l10n = _l10nDe(tester); await tester.tap(find.text(l10n.searchFiltersLabel).first); await tester.pumpAndSettle(); await tester.tap(find.text(label).last); await tester.pumpAndSettle(); } Future _abrirYSeleccionarCalidad( WidgetTester tester, String label, ) async { final l10n = _l10nDe(tester); await tester.tap(find.text(l10n.searchFiltersLabel).first); await tester.pumpAndSettle(); await tester.tap(find.text(label).last); await tester.pumpAndSettle(); } Future _pumpStableFrame(WidgetTester tester) async { await tester.pump(); await tester.pumpAndSettle(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 _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 []);