feat(buscar): add discovery landing state, filter pills, counter, and sort

This commit is contained in:
2026-07-29 08:45:36 +02:00
parent 3a803ce2bf
commit 9bd828139f
29 changed files with 1789 additions and 655 deletions
+39
View File
@@ -373,6 +373,45 @@ void main() {
});
});
group('EstadoRadio orden de listas — persistencia (WU6)', () {
// Correction applied at apply time: cambiarOrdenListas'/_cargarOrdenListas
// round-trip had zero test coverage before WU6 added OrdenEmisoras.
// popularidad — a latent gap similar to WU5's androidAudioSessionIdStream
// discovery. Adding a new enum case without covering the persistence
// switch would have shipped a silent revert-to-calidad-on-restart bug.
test(
'popularidad persiste y sobrevive a una nueva instancia (reinicio)',
() async {
final estadoUno = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estadoUno.cambiarOrdenListas(OrdenEmisoras.popularidad);
expect(estadoUno.ordenListas, OrdenEmisoras.popularidad);
// Fresh instance, same (mocked) SharedPreferences-backed store —
// simulates an app restart re-reading the persisted preference.
final estadoDos = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.ordenListas, OrdenEmisoras.popularidad);
},
);
});
group(
'EstadoRadio — emisoras custom: lectura tolerante y guardia de '
'degradacion (persistence-resilience)',
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/orden_emisoras.dart';
import 'package:pluriwave/modelos/emisora.dart';
/// WU6: every criterion the Buscar "Ordenar" control can render must map to
/// a real, tested [OrdenEmisoras] case (`station-discovery-browse` spec,
/// "Client-Side Search Sort Only" — "the system MUST NOT render an option
/// that does not actually sort").
void main() {
Emisora conMetricas({
required String uuid,
required String nombre,
int? bitrate,
int votes = 0,
int clickcount = 0,
}) => Emisora(
uuid: uuid,
nombre: nombre,
url: 'https://stream.demo/$uuid',
bitrate: bitrate,
votes: votes,
clickcount: clickcount,
);
group('OrdenEmisoras.nombre', () {
test('ordena alfabéticamente sin distinguir mayúsculas', () {
final emisoras = [
conMetricas(uuid: 'z', nombre: 'zeta fm'),
conMetricas(uuid: 'a', nombre: 'Alfa FM'),
conMetricas(uuid: 'm', nombre: 'medio fm'),
];
final resultado = ordenarEmisoras(emisoras, OrdenEmisoras.nombre);
expect(resultado.map((e) => e.uuid), ['a', 'm', 'z']);
});
});
group('OrdenEmisoras.calidad', () {
test('ordena por bitrate descendente', () {
final emisoras = [
conMetricas(uuid: 'baja', nombre: 'Baja', bitrate: 64),
conMetricas(uuid: 'alta', nombre: 'Alta', bitrate: 320),
conMetricas(uuid: 'media', nombre: 'Media', bitrate: 128),
];
final resultado = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
expect(resultado.map((e) => e.uuid), ['alta', 'media', 'baja']);
});
});
group('OrdenEmisoras.popularidad', () {
test('ordena por votos descendente', () {
final emisoras = [
conMetricas(uuid: 'pocos', nombre: 'Pocos', votes: 3),
conMetricas(uuid: 'muchos', nombre: 'Muchos', votes: 900),
conMetricas(uuid: 'medio', nombre: 'Medio', votes: 40),
];
final resultado = ordenarEmisoras(emisoras, OrdenEmisoras.popularidad);
expect(resultado.map((e) => e.uuid), ['muchos', 'medio', 'pocos']);
});
test('en empate de votos, desempata por clickcount descendente', () {
final emisoras = [
conMetricas(
uuid: 'menos-clicks',
nombre: 'Menos clicks',
votes: 10,
clickcount: 5,
),
conMetricas(
uuid: 'mas-clicks',
nombre: 'Mas clicks',
votes: 10,
clickcount: 500,
),
];
final resultado = ordenarEmisoras(emisoras, OrdenEmisoras.popularidad);
expect(resultado.map((e) => e.uuid), ['mas-clicks', 'menos-clicks']);
});
});
test(
'todos los valores del enum tienen un caso de ordenamiento implementado '
'(regresión: nunca ofrecer una opción decorativa que no ordene)',
() {
for (final criterio in OrdenEmisoras.values) {
// Must not throw and must return a same-length list for every case.
final resultado = ordenarEmisoras([
conMetricas(uuid: 'x', nombre: 'X'),
], criterio);
expect(resultado, hasLength(1));
}
},
);
}
+28
View File
@@ -9,6 +9,7 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_favoritos.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
import 'package:pluriwave/servicios/servicio_radio.dart';
class FakeServicioAudio extends ServicioAudio {
@@ -236,6 +237,7 @@ class FakeServicioRadio extends ServicioRadio {
int obtenerPopularesCalls = 0;
int obtenerTendenciasCalls = 0;
int registrarClickCalls = 0;
int buscarCalls = 0;
String? ultimoUuidClick;
Exception _normalizarError(Object error) =>
@@ -279,6 +281,10 @@ class FakeServicioRadio extends ServicioRadio {
int limit = 30,
int offset = 0,
}) async {
// WU6: counts calls so tests can assert a client-side re-sort does NOT
// trigger a new network request (`station-discovery-browse` spec,
// "Client-Side Search Sort Only").
buscarCalls++;
return _busqueda.skip(offset).take(limit).toList();
}
@@ -573,6 +579,28 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
}
}
/// WU6: promoted from a local class in `pantalla_inicio_test.dart` to this
/// shared helpers file, since `pantalla_buscar_test.dart` now needs the same
/// inert stand-in — any widget test that constructs a full `EstadoRadio`
/// must supply a `servicioGrabacion`, or the default REAL
/// `ServicioGrabacionRadio` touches native platform channels and hangs
/// inside `testWidgets()`.
class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
@override
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<void> dispose() => _controller.close();
}
Emisora emisoraDemo({
required String uuid,
required String nombre,
@@ -1,15 +1,26 @@
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/pantallas/pantalla_buscar.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
/// S5-R6: the search loading state uses shimmer placeholders, not a bare
/// spinner, to stay consistent with the rest of the app.
///
/// WU6 correction: `PantallaBuscar` now also renders a discovery LANDING
/// state (task 6.5) that reads `EstadoRadio` directly, so any widget test
/// mounting it — this one included — must provide a full `EstadoRadio`
/// provider from the start, not just `EstadoBusqueda`. A non-empty query is
/// entered first so the screen leaves the landing state and reaches the
/// search-results branch this test actually targets.
class _BusquedaCargando extends EstadoBusqueda {
_BusquedaCargando() : super(radio: FakeServicioRadio());
@@ -18,13 +29,36 @@ class _BusquedaCargando extends EstadoBusqueda {
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('PantallaBuscar muestra shimmer mientras carga', (tester) async {
final busqueda = _BusquedaCargando();
addTearDown(busqueda.dispose);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: () async => throw UnimplementedError(),
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.pumpWidget(
ListenableProvider<EstadoBusqueda>.value(
value: busqueda,
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(
value: estado.ecualizador,
),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
// Overrides EstadoRadio's own (unused here) EstadoBusqueda with a
// fake pinned to cargando: true.
ListenableProvider<EstadoBusqueda>.value(value: busqueda),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -35,6 +69,12 @@ void main() {
);
await tester.pump();
// Still the landing state — no query entered yet.
expect(find.byType(TarjetaEmisoraShimmer), findsNothing);
await tester.enterText(find.byType(SearchBar), 'jazz');
await tester.pump();
expect(find.byType(TarjetaEmisoraShimmer), findsWidgets);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
+480
View File
@@ -0,0 +1,480 @@
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/pantallas/pantalla_buscar.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.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);
expect(find.text(l10n.liveRadar), findsOneWidget);
expect(find.text(l10n.genresTitle), 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('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<OrdenEmisoras>),
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(
'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);
},
);
});
}
EstadoRadio _crearEstado({
FakeServicioAudio? audio,
FakeServicioFavoritos? favoritos,
FakeServicioRadio? radio,
Future<File> 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<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ListenableProvider<EstadoBusqueda>.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<void> _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<void> _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<void> _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<File> _crearArchivoCustom(List<dynamic> emisoras) async {
final nombre =
emisoras.isEmpty
? 'emisoras_custom_vacio.json'
: 'emisoras_custom_uno.json';
return File('${Directory.current.path}/test/fixtures/$nombre');
}
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
@@ -89,8 +89,19 @@ void main() {
expect(registro.any((linea) => linea.contains('PantallaInicio')), isFalse);
// Probe control: a real data change DOES rebuild the screen.
//
// WU6 correction: this used to be `estado.cargarPopulares()`, which
// notifies `emisorasInicio`/`cargandoPopulares` — fields the removed
// discovery grid (`_gridEmisoras`) used to read. Task 6.5 relocated
// that grid to `PantallaBuscar` and deleted it here, so
// `PantallaInicio` no longer selects either field; `cargarPopulares()`
// would silently turn this probe into a false negative instead of a
// real control. Toggling a favorite is the correct replacement — the
// remaining "Tus emisoras" section (`_seccionTusEmisoras`) selects
// `listaFavoritos` directly.
registro.clear();
await tester.runAsync(estado.cargarPopulares);
final emisoraProbe = emisoraDemo(uuid: 'probe-1', nombre: 'Probe Uno');
await tester.runAsync(() => estado.toggleFavorito(emisoraProbe));
await tester.pump();
expect(registro.any((linea) => linea.contains('PantallaInicio')), isTrue);
debugPrintRebuildDirtyWidgets = false;
+12 -194
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -9,10 +8,7 @@ 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';
@@ -23,162 +19,16 @@ void main() {
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);
});
// 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 '
@@ -317,22 +167,6 @@ class _RecordingNavigatorObserver extends NavigatorObserver {
}
}
class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
@override
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<void> dispose() => _controller.close();
}
Future<void> _pumpStableFrame(WidgetTester tester) async {
await tester.pump();
await tester.pumpAndSettle(const Duration(milliseconds: 100));
@@ -354,21 +188,5 @@ void _setLargeSurfaceSize(WidgetTester tester) {
addTearDown(tester.view.resetDevicePixelRatio);
}
Future<void> _scrollUntilText(WidgetTester tester, String text) async {
await tester.scrollUntilVisible(
find.text(text),
300,
scrollable: find.byType(Scrollable).first,
);
await _pumpStableFrame(tester);
}
Future<File> _crearArchivoCustom(List<dynamic> emisoras) async {
final nombre =
emisoras.isEmpty
? 'emisoras_custom_vacio.json'
: 'emisoras_custom_uno.json';
return File('${Directory.current.path}/test/fixtures/$nombre');
}
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
Future<File> _archivoCustomVacio() async =>
File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json');
+23 -4
View File
@@ -109,12 +109,31 @@ void main() {
testWidgets('PantallaBuscar', (tester) async {
setLargeSurface(tester);
final busqueda = EstadoBusqueda(radio: FakeServicioRadio());
addTearDown(busqueda.dispose);
// WU6 correction: PantallaBuscar now also owns the discovery landing
// state relocated from PantallaInicio (task 6.5), which reads
// EstadoRadio directly (near-you/trending/browse-grid selectors) —
// a bare EstadoBusqueda provider is no longer sufficient.
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.pumpWidget(
ListenableProvider<EstadoBusqueda>.value(
value: busqueda,
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(
value: estado.ecualizador,
),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
],
child: testApp(const PantallaBuscar()),
),
);