Item 24 / audit 5.1-5.3, 5.5 (t4:255-269): Paises' "Tus idiomas" was a Wrap of non-interactive Chips, and the full list was a plain ListTile with no ISO column and no onTap. Both now share one tappable row (ISO code, name, station count, chevron); the first "Tus idiomas" row gets the prototype's teal-tinted highlight. The one production call site wires the tap to filter Buscar by that country's code and pop back -- EstadoBusqueda.buscar(pais: ...) already accepts any ISO alpha-2 code, not just the ~10 presets in the filter sheet. Item 25 / audit 13.2 (t4:643-646): a reconnect card (rotating ring, station name, "Reconectando...", a stop affordance) replaces the complete absence of any reconnect signal outside a word in the mini player. Ships WITHOUT the prototype's attempt counter: the only live ControladorReconexion instance is a private field of PluriWaveAudioHandler inside servicio_audio.dart, a file this task requires stay byte-identical to main, and nothing else re-exposes it. Reconstructing a count from estadoStream's reconectando emissions would not be faithful (the stream can emit it many times per actual backoff attempt), so that was deliberately not attempted.
922 lines
33 KiB
Dart
922 lines
33 KiB
Dart
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/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('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(
|
|
'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<DecoratedBox>(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<Icon>(
|
|
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<Icon>(
|
|
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(
|
|
'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('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<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 []);
|