diff --git a/lib/estado/estado_busqueda.dart b/lib/estado/estado_busqueda.dart index b89f85c..a326cde 100644 --- a/lib/estado/estado_busqueda.dart +++ b/lib/estado/estado_busqueda.dart @@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; +import '../modelos/pais_radio.dart'; import '../servicios/servicio_radio.dart'; import 'orden_emisoras.dart'; @@ -50,6 +51,8 @@ class EstadoBusqueda extends ChangeNotifier { String? _ultimoIdioma; String? _ultimoTag; int? _ultimoMinBitrate; + List _paises = []; + bool _cargandoPaises = false; final _memoResultados = MemoLista(); final _memoCercanas = MemoLista(); @@ -68,6 +71,8 @@ class EstadoBusqueda extends ChangeNotifier { bool get cargandoCercanas => _cargandoCercanas; String? get paisCercanoDetectado => _paisCercanoDetectado; String? get errorCercanas => _errorCercanas; + List get paises => _paises; + bool get cargandoPaises => _cargandoPaises; /// Re-renders sorted views after the user changes the list ordering /// (called by EstadoRadio, which owns that preference). @@ -219,4 +224,33 @@ class EstadoBusqueda extends ChangeNotifier { notifyListeners(); } } + + /// Fetches the Países browser's country list (WU7, `station-discovery-browse` + /// spec). In-memory cache guard: once populated, re-entering the screen + /// does not refetch — this is deliberately NOT time-based invalidation, + /// since the country/station-count universe changes on a scale of days, + /// not per app session. + /// + /// Sorted once here (case-insensitive by name, same convention as + /// `ordenarEmisoras`'s `OrdenEmisoras.nombre` case) since the API orders by + /// raw byte order, not proper collation (design ADR-4). + Future cargarPaises() async { + if (_paises.isNotEmpty || _cargandoPaises) return; + _cargandoPaises = true; + notifyListeners(); + try { + // Defensive copy: `radio.obtenerPaises()` makes no growable/mutable + // guarantee about the list it returns (tests may hand back a `const` + // list) — sorting in place would throw on an unmodifiable list. + final ordenados = List.of(await radio.obtenerPaises())..sort( + (a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()), + ); + _paises = ordenados; + } catch (_) { + _alError?.call(_textos().radioCountriesError); + } finally { + _cargandoPaises = false; + notifyListeners(); + } + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 137378f..bd17283 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -309,6 +309,10 @@ "searchNoResultsSubtitle": "Try removing filters or typing another name to find an active station.", "searchResultsCount": "{count, plural, =1{1 result} other{{count} results}}", "searchClearFiltersAction": "{count, plural, =1{Clear filter} other{Clear {count} filters}}", + "countriesScreenTitle": "Countries", + "countriesYourLanguagesTitle": "Your languages", + "countriesAllTitle": "All countries", + "radioCountriesError": "We couldn't load the countries.", "countrySpain": "Spain", "countryUsa": "USA", "countryMexico": "Mexico", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 870911e..1cfb40d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -309,6 +309,10 @@ "searchNoResultsSubtitle": "Probá quitar filtros o escribir otro nombre para encontrar una señal activa.", "searchResultsCount": "{count, plural, =1{1 resultado} other{{count} resultados}}", "searchClearFiltersAction": "{count, plural, =1{Quitar el filtro} other{Quitar los {count} filtros}}", + "countriesScreenTitle": "Países", + "countriesYourLanguagesTitle": "Tus idiomas", + "countriesAllTitle": "Todos los países", + "radioCountriesError": "No pudimos cargar los países.", "countrySpain": "España", "countryUsa": "EE. UU.", "countryMexico": "México", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 9ade1d3..c369987 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1150,6 +1150,30 @@ abstract class AppLocalizations { /// **'{count, plural, =1{Quitar el filtro} other{Quitar los {count} filtros}}'** String searchClearFiltersAction(num count); + /// No description provided for @countriesScreenTitle. + /// + /// In es, this message translates to: + /// **'Países'** + String get countriesScreenTitle; + + /// No description provided for @countriesYourLanguagesTitle. + /// + /// In es, this message translates to: + /// **'Tus idiomas'** + String get countriesYourLanguagesTitle; + + /// No description provided for @countriesAllTitle. + /// + /// In es, this message translates to: + /// **'Todos los países'** + String get countriesAllTitle; + + /// No description provided for @radioCountriesError. + /// + /// In es, this message translates to: + /// **'No pudimos cargar los países.'** + String get radioCountriesError; + /// No description provided for @countrySpain. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index ddc0ddf..eb52346 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -611,6 +611,18 @@ class AppLocalizationsAr extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'إسبانيا'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 7dca4e1..5e5d9d3 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -615,6 +615,18 @@ class AppLocalizationsBn extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'স্পেন'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 78272fc..7d1a732 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -618,6 +618,18 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'Spanien'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 0aec379..eae8ffa 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -612,6 +612,18 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Countries'; + + @override + String get countriesYourLanguagesTitle => 'Your languages'; + + @override + String get countriesAllTitle => 'All countries'; + + @override + String get radioCountriesError => 'We couldn\'t load the countries.'; + @override String get countrySpain => 'Spain'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 57690fd..0fa9cfd 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -616,6 +616,18 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'España'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index ca203e5..064847e 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -620,6 +620,18 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'Espagne'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 1262ca3..11a3108 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -613,6 +613,18 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'स्पेन'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 08e172f..3ecb762 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -614,6 +614,18 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'Spanyol'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index c2ba32a..4b2b60f 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -616,6 +616,18 @@ class AppLocalizationsIt extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'Spagna'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 51f3f71..b0865fe 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -593,6 +593,18 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'スペイン'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 9175673..82a34d2 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -615,6 +615,18 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'Espanha'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 8cbd720..9ee755a 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -616,6 +616,18 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => 'Испания'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index f304586..9d8b01c 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -591,6 +591,18 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String get countriesScreenTitle => 'Países'; + + @override + String get countriesYourLanguagesTitle => 'Tus idiomas'; + + @override + String get countriesAllTitle => 'Todos los países'; + + @override + String get radioCountriesError => 'No pudimos cargar los países.'; + @override String get countrySpain => '西班牙'; diff --git a/lib/modelos/pais_radio.dart b/lib/modelos/pais_radio.dart new file mode 100644 index 0000000..f7285b1 --- /dev/null +++ b/lib/modelos/pais_radio.dart @@ -0,0 +1,28 @@ +/// Country entry from the Radio Browser `/json/countries` endpoint. +/// +/// `stationcount` arrives as a JSON **string**, not a number — an `as int` +/// cast throws at runtime. This model always goes through `int.tryParse` +/// and defaults safely when a field is missing or malformed (Engram +/// `reference/radio-browser-countries-endpoint`, id 2500). +class PaisRadio { + const PaisRadio({ + required this.nombre, + required this.codigoIso, + required this.numeroEmisoras, + }); + + /// `name` — country name as the API returns it (not translated). + final String nombre; + + /// `iso_3166_1` — ISO 3166-1 alpha-2, normalized to uppercase. + final String codigoIso; + + /// `stationcount`, parsed from its JSON string form. + final int numeroEmisoras; + + factory PaisRadio.fromApi(Map json) => PaisRadio( + nombre: json['name'] as String? ?? '', + codigoIso: (json['iso_3166_1'] as String? ?? '').toUpperCase(), + numeroEmisoras: int.tryParse('${json['stationcount'] ?? ''}') ?? 0, + ); +} diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index 21dcd76..11743d2 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -11,8 +11,10 @@ import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; +import '../widgets/pluri_push_scaffold.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; +import 'pantalla_paises.dart'; import 'reproducir_minimizado.dart'; const _paises = [ @@ -190,6 +192,7 @@ class _PantallaBuscarState extends State { _seccionCercanas(context, theme, l10n), _seccionTendencias(context, theme, l10n), _chipGeneros(context, theme, l10n), + _seccionPaises(context, theme, l10n), if (context.select((e) => e.error) != null) _errorBanner( context, @@ -213,21 +216,15 @@ class _PantallaBuscarState extends State { final l10n = AppLocalizations.of(context); final pills = [ if (_paisSeleccionado != null) - _pillFiltro( - _paisLabelSeleccionado(l10n) ?? _paisSeleccionado!, - () { - setState(() => _paisSeleccionado = null); - _buscar(); - }, - ), + _pillFiltro(_paisLabelSeleccionado(l10n) ?? _paisSeleccionado!, () { + setState(() => _paisSeleccionado = null); + _buscar(); + }), if (_idiomaSeleccionado != null) - _pillFiltro( - _idiomaLabelSeleccionado(l10n) ?? _idiomaSeleccionado!, - () { - setState(() => _idiomaSeleccionado = null); - _buscar(); - }, - ), + _pillFiltro(_idiomaLabelSeleccionado(l10n) ?? _idiomaSeleccionado!, () { + setState(() => _idiomaSeleccionado = null); + _buscar(); + }), if (_calidadMinima != null) _pillFiltro('≥$_calidadMinima kbps', () { setState(() => _calidadMinima = null); @@ -249,7 +246,8 @@ class _PantallaBuscarState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (pills.isNotEmpty) Wrap(spacing: 8, runSpacing: 8, children: pills), + if (pills.isNotEmpty) + Wrap(spacing: 8, runSpacing: 8, children: pills), if (pills.isNotEmpty && estado.resultados.isNotEmpty) const SizedBox(height: 10), if (!estado.cargando && estado.resultados.isNotEmpty) @@ -438,9 +436,7 @@ class _PantallaBuscarState extends State { selected: seleccionado == value, visualDensity: VisualDensity.compact, onSelected: - (_) => onChanged( - seleccionado == value ? null : value, - ), + (_) => onChanged(seleccionado == value ? null : value), ), ], ), @@ -486,9 +482,7 @@ class _PantallaBuscarState extends State { selected: seleccionado == value, visualDensity: VisualDensity.compact, onSelected: - (_) => onChanged( - seleccionado == value ? null : value, - ), + (_) => onChanged(seleccionado == value ? null : value), ), ], ), @@ -527,7 +521,9 @@ class _PantallaBuscarState extends State { child: PluriEmptyState( glyph: PluriIconGlyph.search, title: - sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle, + sinFiltros + ? l10n.searchEmptyTitle + : l10n.searchNoResultsTitle, subtitle: sinFiltros ? l10n.searchEmptySubtitle @@ -803,6 +799,46 @@ class _PantallaBuscarState extends State { ); } + /// WU7, `station-discovery-browse` spec: the "Países entry point" the + /// spec's landing-state scenario lists. Deferred from WU6 (this class's + /// own doc comment: `PantallaPaises` did not exist yet); added here now + /// that it does, alongside the screen it targets — an unreachable screen + /// would repeat the WU15/WU15b lesson (a fully-tested screen shipped with + /// no navigation path to it). + Widget _seccionPaises( + BuildContext context, + ThemeData theme, + AppLocalizations l10n, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 8, + PluriLayout.horizontal, + 0, + ), + child: PluriGlassSurface( + padding: EdgeInsets.zero, + // `PluriGlassSurface` paints via `DecoratedBox`, not `Material` — a + // tappable `ListTile` needs its own `Material` ancestor or its ink + // splash silently fails to paint (Flutter's own debug assertion). + child: Material( + type: MaterialType.transparency, + child: ListTile( + leading: const Icon(Icons.public_rounded), + title: Text(l10n.countriesScreenTitle), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: + () => PluriPushScaffold.push( + context, + (_) => const PantallaPaises(), + ), + ), + ), + ), + ); + } + Widget _errorBanner( BuildContext context, String error, @@ -892,7 +928,11 @@ class _PantallaBuscarState extends State { (context, i) => TarjetaEmisora( emisora: emisoras[i], onTap: () => reproducirMinimizado(context, emisoras[i]), - ).pluriFadeSlideIn(context, delay: Duration(milliseconds: i * 30), beginY: 0.1), + ).pluriFadeSlideIn( + context, + delay: Duration(milliseconds: i * 30), + beginY: 0.1, + ), ); } } diff --git a/lib/pantallas/pantalla_paises.dart b/lib/pantallas/pantalla_paises.dart new file mode 100644 index 0000000..9069d7f --- /dev/null +++ b/lib/pantallas/pantalla_paises.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../estado/estado_busqueda.dart'; +import '../l10n/gen/app_localizations.dart'; +import '../modelos/pais_radio.dart'; +import '../widgets/pluri_glass_surface.dart'; +import '../widgets/pluri_layout.dart'; +import '../widgets/pluri_push_scaffold.dart'; + +/// WU7, `station-discovery-browse` spec — "Países Browser Over the Verified +/// Countries Contract". Pure display over `EstadoBusqueda.cargarPaises()` +/// (in-memory cache guard, so re-entering this screen never re-fetches): +/// a "Tus idiomas" shortlist above the full alphabetical list, both with +/// live station counts already parsed from the API's `stationcount` +/// **string** field (`PaisRadio.fromApi`, Engram id 2500). +class PantallaPaises extends StatefulWidget { + const PantallaPaises({super.key}); + + @override + State createState() => _PantallaPaisesState(); +} + +class _PantallaPaisesState extends State { + /// One representative country per app-supported locale (the same 13 + /// locales as `pantalla_ajustes_idioma.dart`'s `_idiomas` list). "Tus + /// idiomas" is named by the proposal/spec but its derivation is not + /// otherwise specified — this reuses the app's own existing language + /// identity rather than inventing a separate curated country list. + static const _paisPorIdioma = { + 'en': 'US', + 'es': 'ES', + 'zh': 'CN', + 'hi': 'IN', + 'ar': 'SA', + 'pt': 'PT', + 'fr': 'FR', + 'ru': 'RU', + 'de': 'DE', + 'ja': 'JP', + 'id': 'ID', + 'bn': 'BD', + 'it': 'IT', + }; + + @override + void initState() { + super.initState(); + // `cargarPaises()` calls `notifyListeners()` before its first `await` + // (its loading-flag flip) — doing that synchronously inside `initState` + // would trigger "setState() or markNeedsBuild() called during build." + // Deferred to a post-frame callback, matching the established pattern + // for triggering a load from a screen's `initState`/`build` + // (`pantalla_alarmas.dart`'s `_favoritosSolicitados` guard). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().cargarPaises(); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final estado = context.watch(); + + return PluriPushScaffold( + title: l10n.countriesScreenTitle, + body: + estado.cargandoPaises && estado.paises.isEmpty + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: PluriLayout.pageContentPadding, + children: [ + _seccionTusIdiomas(context, estado.paises, l10n), + const SizedBox(height: 16), + _seccionTodos(context, estado.paises, l10n), + ], + ), + ); + } + + Widget _seccionTusIdiomas( + BuildContext context, + List paises, + AppLocalizations l10n, + ) { + final porCodigo = {for (final p in paises) p.codigoIso: p}; + final destacados = + _paisPorIdioma.values + .map((codigo) => porCodigo[codigo]) + .whereType() + .toList(); + + if (destacados.isEmpty) return const SizedBox.shrink(); + + final theme = Theme.of(context); + return PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.countriesYourLanguagesTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final pais in destacados) + Chip( + label: Text( + '${pais.nombre} · ${l10n.stationsCount(pais.numeroEmisoras)}', + ), + ), + ], + ), + ], + ), + ); + } + + Widget _seccionTodos( + BuildContext context, + List paises, + AppLocalizations l10n, + ) { + final theme = Theme.of(context); + return PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.countriesAllTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: paises.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final pais = paises[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(pais.nombre), + trailing: Text(l10n.stationsCount(pais.numeroEmisoras)), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/servicios/servicio_radio.dart b/lib/servicios/servicio_radio.dart index 54419d8..2383506 100644 --- a/lib/servicios/servicio_radio.dart +++ b/lib/servicios/servicio_radio.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import '../modelos/emisora.dart'; +import '../modelos/pais_radio.dart'; /// Cliente para la Radio Browser API (https://api.radio-browser.info/). /// @@ -157,7 +158,25 @@ class ServicioRadio { return _descubrimientoEnCurso!; } - Future> _get(String path, Map params) async { + /// Transport ONLY: server discovery, host rotation, bounded retries, + /// User-Agent, timeout, status check, `json.decode`, sticky-host + /// bookkeeping. No filters, no models, no ordering — deliberately + /// sort-agnostic and filter-agnostic so non-station endpoints (e.g. + /// `/json/countries`, via [obtenerPaises]) can reuse this resilience + /// behaviour without inheriting station-only semantics such as + /// `lastcheckok` or bitrate ordering (design ADR-4). + /// + /// Extracted verbatim from `_get` — no logic edits — so the 8 existing + /// station calls stay byte-identical + /// (`test/servicios/servicio_radio_transporte_test.dart`). + /// + /// Named behaviour delta, accepted per ADR-4: `_servidorActual` is set here + /// on success/failure, so a successful `/json/countries` call now warms + /// the sticky host for subsequent station calls too. + Future> _getJson( + String path, + Map params, + ) async { await _descubrirServidores(); Exception? ultimoError; final indiceBase = _indiceServidorInicial(); @@ -165,14 +184,11 @@ class ServicioRadio { for (int intento = 0; intento < totalIntentos; intento++) { final servidor = _servidorPorIntento(indiceBase, intento); - final uri = _uri(servidor, path, {'lastcheckok': '1', ...params}); + final uri = _uri(servidor, path, params); try { final resp = await _cliente - .get( - uri, - headers: {'User-Agent': await _resolverUserAgent()}, - ) + .get(uri, headers: {'User-Agent': await _resolverUserAgent()}) .timeout(_timeout); if (resp.statusCode != 200) { @@ -181,14 +197,7 @@ class ServicioRadio { final lista = json.decode(resp.body) as List; _servidorActual = servidor; - final emisoras = - lista - .cast>() - .map(Emisora.fromApi) - .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) - .toList(); - emisoras.sort(_compararCalidad); - return emisoras; + return lista; } on Exception catch (e) { ultimoError = e; _servidorActual = null; @@ -203,6 +212,22 @@ class ServicioRadio { throw ultimoError ?? Exception('Error desconocido al consultar la API'); } + /// Station layer over [_getJson]: adds the station-only `lastcheckok` + /// filter, maps to [Emisora], drops entries with an empty `uuid`/`url`, + /// and applies the quality sort. None of this belongs in transport — see + /// [_getJson]'s doc comment. + Future> _get(String path, Map params) async { + final lista = await _getJson(path, {'lastcheckok': '1', ...params}); + final emisoras = + lista + .cast>() + .map(Emisora.fromApi) + .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) + .toList(); + emisoras.sort(_compararCalidad); + return emisoras; + } + /// Emisoras más votadas globalmente. Future> obtenerPopulares({ int limit = 30, @@ -301,6 +326,34 @@ class ServicioRadio { }); } + /// Países disponibles vía `/json/countries` (station-discovery-browse + /// spec — "Países Browser Over the Verified Countries Contract"). + /// + /// Deliberately reuses [_getJson], never [_get]: + /// - **No `lastcheckok`.** That filter is station-only and meaningless on + /// a countries listing — sending it would be the whole bug this + /// extraction exists to avoid (Engram id 2500). + /// - **No `order` parameter.** Not because the endpoint default is + /// convenient, but because the screen sorts client-side by name anyway: + /// the API orders by raw byte order, which is not proper collation for + /// any locale this app ships (Engram id 2505's client-side-sort + /// reasoning applies here too). + /// - `hidebroken=true` is still applied (inherited from `_uri`, unchanged) + /// — the endpoint's own default is `false`, so this keeps dead stations + /// out of the per-country counts, which is desirable, not a station-only + /// concern. + /// + /// The `.where(...)` guard mirrors `_get`'s own precedent of dropping + /// entries with empty required fields — not a claim about API behaviour. + Future> obtenerPaises() async { + final lista = await _getJson('/json/countries', const {}); + return lista + .whereType>() + .map(PaisRadio.fromApi) + .where((p) => p.nombre.isNotEmpty && p.codigoIso.length == 2) + .toList(); + } + int _compararCalidad(Emisora a, Emisora b) { final bitrateA = a.bitrate ?? 0; final bitrateB = b.bitrate ?? 0; diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index f445bab..cf8b51b 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -467,28 +467,28 @@ Calls Unchanged by Transport Extraction > **Special ordering rule (non-negotiable): task 7.1 must be green before task 7.4 begins.** Its shuffled-fixture > UUID-sequence assertion is what makes the extraction safe — skipping it lets a sort silently sink into transport. -- [ ] 7.1 RED→GREEN (characterisation, green by construction against the **unmodified** `_get`) — create +- [x] 7.1 RED→GREEN (characterisation, green by construction against the **unmodified** `_get`) — create `servicio_radio_transporte_test.dart` pinning, for each of the 7 `_get`-based methods (`obtenerPopulares`, `obtenerTendencias`, `buscarPorNombre`, `buscarPorPais`, `buscarPorIdioma`, `buscarPorTag`, `buscar`): request path, `lastcheckok=1` present, `hidebroken=true` present, non-empty `User-Agent`, exact `order`/`reverse`/`limit`/`offset`, and the exact returned UUID sequence from a fixture with deliberately shuffled `bitrate`/`clickcount`/`votes`. Pin `registrarClick`'s path and that it sends *some* `User-Agent` (do **not** pin its known-stale literal at line 325 — that would convert a bug into a contract). -- [ ] 7.2 RED — `PaisRadio.fromApi` parses `stationcount` via `int.tryParse` from a fixture where it arrives as a +- [x] 7.2 RED — `PaisRadio.fromApi` parses `stationcount` via `int.tryParse` from a fixture where it arrives as a JSON **string**; defaults safely on missing fields. -- [ ] 7.3 RED — `estado_busqueda_test.dart` additions for `paises`, `cargandoPaises`, `cargarPaises()` (in-memory +- [x] 7.3 RED — `estado_busqueda_test.dart` additions for `paises`, `cargandoPaises`, `cargarPaises()` (in-memory cache guard); `pantalla_paises_test.dart` for "Tus idiomas" + full alphabetical list with counts. -- [ ] 7.4 GREEN — extract `_getJson` (transport only: `_descubrirServidores`, host rotation, bounded retries, +- [x] 7.4 GREEN — extract `_getJson` (transport only: `_descubrirServidores`, host rotation, bounded retries, User-Agent, timeout, status check, `json.decode`, sticky-host bookkeeping) out of `_get`, moved as **one block, no logic edits**; `_get` re-implemented on top, still applying `lastcheckok`, `Emisora.fromApi`, `_compararCalidad`. -- [ ] 7.5 GREEN — implement `lib/modelos/pais_radio.dart`, `obtenerPaises()` (calls +- [x] 7.5 GREEN — implement `lib/modelos/pais_radio.dart`, `obtenerPaises()` (calls `_getJson('/json/countries', const {})`, no `lastcheckok`, no `order`), and `lib/pantallas/pantalla_paises.dart`. -- [ ] 7.6 RE-RUN (proof step) — re-run task 7.1's tests against the extracted code; must be byte-identical green, +- [x] 7.6 RE-RUN (proof step) — re-run task 7.1's tests against the extracted code; must be byte-identical green, including the shuffled-fixture UUID-sequence assertion. -- [ ] 7.7 REFACTOR — document in the commit body that a successful `/json/countries` call now warms +- [x] 7.7 REFACTOR — document in the commit body that a successful `/json/countries` call now warms `_servidorActual` for subsequent station calls — an accepted, intentional behaviour delta, not a regression. -- [ ] 7.8 Verify — grep confirms `countrycodes` appears nowhere in `lib/`; the string-`stationcount` fixture renders +- [x] 7.8 Verify — grep confirms `countrycodes` appears nowhere in `lib/`; the string-`stationcount` fixture renders `482` without a cast error; the countries request has no `lastcheckok`; all 8 original methods' tests pass identically before and after. diff --git a/test/estado/estado_busqueda_test.dart b/test/estado/estado_busqueda_test.dart index b76a4c0..0a89a1a 100644 --- a/test/estado/estado_busqueda_test.dart +++ b/test/estado/estado_busqueda_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_busqueda.dart'; +import 'package:pluriwave/modelos/pais_radio.dart'; import '../helpers/fakes.dart'; @@ -64,4 +65,61 @@ void main() { expect(identical(busqueda.resultados, busqueda.resultados), isTrue); }, ); + + // --------------------------------------------------------------------- + // WU7, `station-discovery-browse` spec — Países browser over + // `ServicioRadio.obtenerPaises()`. In-memory cache guard: re-entering the + // Países screen must NOT re-fetch (design ADR-4). + // --------------------------------------------------------------------- + group('EstadoBusqueda — cargarPaises', () { + test( + 'carga la lista de países y no vuelve a pedir en un 2º llamado', + () async { + final radio = FakeServicioRadio( + paises: const [ + PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482), + PaisRadio( + nombre: 'Argentina', + codigoIso: 'AR', + numeroEmisoras: 120, + ), + ], + ); + final busqueda = EstadoBusqueda(radio: radio); + addTearDown(busqueda.dispose); + + expect(busqueda.paises, isEmpty); + expect(busqueda.cargandoPaises, isFalse); + + await busqueda.cargarPaises(); + + expect( + busqueda.paises.map((p) => p.codigoIso), + containsAll(['ES', 'AR']), + ); + expect(busqueda.cargandoPaises, isFalse); + expect(radio.obtenerPaisesCalls, 1); + + await busqueda.cargarPaises(); + + // Cache guard: a 2nd call with data already present must not refetch. + expect(radio.obtenerPaisesCalls, 1); + }, + ); + + test( + 'falla con gracia: no deja cargandoPaises en true ni la lista poblada', + () async { + final busqueda = EstadoBusqueda( + radio: FakeServicioRadio(errorPaises: Exception('fallo de red')), + ); + addTearDown(busqueda.dispose); + + await busqueda.cargarPaises(); + + expect(busqueda.cargandoPaises, isFalse); + expect(busqueda.paises, isEmpty); + }, + ); + }); } diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 15d5fa6..6c2c2f7 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -4,6 +4,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/dispositivo_audio.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart'; +import 'package:pluriwave/modelos/pais_radio.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart'; @@ -218,13 +219,17 @@ class FakeServicioRadio extends ServicioRadio { List>? tendenciasPorLlamada, List? erroresPopularesPorLlamada, List? erroresTendenciasPorLlamada, + List? paises, + Object? errorPaises, }) : _populares = populares ?? [], _tendencias = tendencias ?? [], _busqueda = busqueda ?? [], _popularesPorLlamada = popularesPorLlamada ?? const [], _tendenciasPorLlamada = tendenciasPorLlamada ?? const [], _erroresPopularesPorLlamada = erroresPopularesPorLlamada ?? const [], - _erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const []; + _erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const [], + _paises = paises ?? [], + _errorPaises = errorPaises; final List _populares; final List _tendencias; @@ -233,11 +238,14 @@ class FakeServicioRadio extends ServicioRadio { final List> _tendenciasPorLlamada; final List _erroresPopularesPorLlamada; final List _erroresTendenciasPorLlamada; + final List _paises; + final Object? _errorPaises; int obtenerPopularesCalls = 0; int obtenerTendenciasCalls = 0; int registrarClickCalls = 0; int buscarCalls = 0; + int obtenerPaisesCalls = 0; String? ultimoUuidClick; Exception _normalizarError(Object error) => @@ -293,6 +301,14 @@ class FakeServicioRadio extends ServicioRadio { registrarClickCalls += 1; ultimoUuidClick = uuid; } + + @override + Future> obtenerPaises() async { + obtenerPaisesCalls++; + final error = _errorPaises; + if (error != null) throw _normalizarError(error); + return _paises; + } } class FakeServicioEcualizador extends ServicioEcualizador { diff --git a/test/modelos/pais_radio_test.dart b/test/modelos/pais_radio_test.dart new file mode 100644 index 0000000..7ffc65a --- /dev/null +++ b/test/modelos/pais_radio_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/pais_radio.dart'; + +/// WU7 / `station-discovery-browse` spec, scenario "`stationcount` arrives +/// as a JSON string (edge case, critical)": the Radio Browser `/json/countries` +/// endpoint returns `stationcount` as a STRING, not a number. An `as int` +/// cast throws at runtime — this model MUST go through `int.tryParse` +/// (Engram `reference/radio-browser-countries-endpoint`, id 2500). +void main() { + group('PaisRadio.fromApi', () { + test('parsea stationcount desde un string JSON sin lanzar', () { + final pais = PaisRadio.fromApi(const { + 'name': 'Spain', + 'iso_3166_1': 'es', + 'stationcount': '482', + }); + + expect(pais.nombre, 'Spain'); + expect(pais.codigoIso, 'ES'); + expect(pais.numeroEmisoras, 482); + }); + + test('normaliza iso_3166_1 a mayúsculas', () { + final pais = PaisRadio.fromApi(const { + 'name': 'France', + 'iso_3166_1': 'fr', + 'stationcount': '10', + }); + + expect(pais.codigoIso, 'FR'); + }); + + test('stationcount ausente cae a 0 sin lanzar', () { + final pais = PaisRadio.fromApi(const { + 'name': 'Nowhere', + 'iso_3166_1': 'xx', + }); + + expect(pais.numeroEmisoras, 0); + }); + + test('stationcount no numérico cae a 0 sin lanzar', () { + final pais = PaisRadio.fromApi(const { + 'name': 'Nowhere', + 'iso_3166_1': 'xx', + 'stationcount': 'not-a-number', + }); + + expect(pais.numeroEmisoras, 0); + }); + + test('name/iso_3166_1 ausentes caen a string vacío sin lanzar', () { + final pais = PaisRadio.fromApi(const {'stationcount': '5'}); + + expect(pais.nombre, ''); + expect(pais.codigoIso, ''); + expect(pais.numeroEmisoras, 5); + }); + }); +} diff --git a/test/pantallas/pantalla_paises_test.dart b/test/pantallas/pantalla_paises_test.dart new file mode 100644 index 0000000..d6c64fb --- /dev/null +++ b/test/pantallas/pantalla_paises_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_busqueda.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/pais_radio.dart'; +import 'package:pluriwave/pantallas/pantalla_paises.dart'; +import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; +import 'package:provider/provider.dart'; + +import '../helpers/fakes.dart'; + +/// WU7, `station-discovery-browse` spec — "Países Browser Over the Verified +/// Countries Contract": "Tus idiomas" shortlist + the full alphabetical +/// list, each entry showing its station count. `stationcount`'s "arrives as +/// a JSON string" edge case is covered at its own layer boundary in +/// `test/modelos/pais_radio_test.dart` — `FakeServicioRadio.obtenerPaises()` +/// returns already-parsed `PaisRadio` instances here, so this file tests +/// rendering only, not JSON parsing. +void main() { + Widget buildScreen(EstadoBusqueda estado) { + return ChangeNotifierProvider.value( + value: estado, + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const PantallaPaises(), + ), + ); + } + + Future pumpEstable(WidgetTester tester) async { + await tester.pump(); + await tester.pumpAndSettle(); + } + + testWidgets( + 'renders inside a PluriPushScaffold; muestra "Tus idiomas" y la lista ' + 'alfabética completa con el conteo de cada país', + (tester) async { + final estado = EstadoBusqueda( + radio: FakeServicioRadio( + paises: const [ + PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482), + PaisRadio( + nombre: 'Argentina', + codigoIso: 'AR', + numeroEmisoras: 120, + ), + PaisRadio(nombre: 'France', codigoIso: 'FR', numeroEmisoras: 75), + ], + ), + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await pumpEstable(tester); + + expect(find.byType(PluriPushScaffold), findsOneWidget); + + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaPaises)), + ); + expect(find.text(l10n.countriesYourLanguagesTitle), findsOneWidget); + expect(find.text(l10n.countriesAllTitle), findsOneWidget); + + // Full alphabetical list: all 3 fetched countries render. + expect(find.text('Argentina'), findsOneWidget); + expect(find.text('France'), findsOneWidget); + // 'Spain' renders twice: "Tus idiomas" (locale es -> representative + // country ES) AND the full list below. + expect(find.text('Spain'), findsWidgets); + + // Each entry's parsed `stationcount` renders via the existing + // `stationsCount` string — 482 came from the API as a STRING + // (`PaisRadio.fromApi`'s job, verified separately) and must display + // as a plain number here, never throwing a cast error. + expect(find.text(l10n.stationsCount(482)), findsWidgets); + expect(find.text(l10n.stationsCount(120)), findsOneWidget); + expect(find.text(l10n.stationsCount(75)), findsOneWidget); + }, + ); + + testWidgets( + 'cargarPaises no se re-dispara si ya hay datos en caché al reconstruir', + (tester) async { + final radio = FakeServicioRadio( + paises: const [ + PaisRadio(nombre: 'Italy', codigoIso: 'IT', numeroEmisoras: 30), + ], + ); + final estado = EstadoBusqueda(radio: radio); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildScreen(estado)); + await pumpEstable(tester); + expect(radio.obtenerPaisesCalls, 1); + + // Re-entering the screen (a fresh State, same EstadoBusqueda instance) + // must not refetch — the in-memory cache guard lives on EstadoBusqueda, + // not on the widget. + await tester.pumpWidget(const SizedBox.shrink()); + await pumpEstable(tester); + await tester.pumpWidget(buildScreen(estado)); + await pumpEstable(tester); + + expect(radio.obtenerPaisesCalls, 1); + }, + ); +} diff --git a/test/servicios/servicio_radio_transporte_test.dart b/test/servicios/servicio_radio_transporte_test.dart new file mode 100644 index 0000000..59fd655 --- /dev/null +++ b/test/servicios/servicio_radio_transporte_test.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/servicio_radio.dart'; + +/// Characterisation tests for all 8 existing `ServicioRadio` station calls, +/// written against the CURRENT (unmodified) `_get`/`registrarClick` — green +/// by construction, per design ADR-4's strict-TDD sequence: "naive 'the test +/// must fail first' does not apply to characterisation." +/// +/// Design's non-negotiable ordering rule: this file MUST be green BEFORE the +/// `_getJson` transport extraction begins, and re-run byte-identical green +/// AFTER it (task 7.6) — that re-run is the extraction's actual proof. +/// +/// `test/servicios/servicio_radio_test.dart` is intentionally NOT modified +/// by this work unit; its passing untouched is itself a signal that +/// transport was not disturbed. +void main() { + // Deliberately shuffled bitrate/clickcount/votes: by bitrate desc, `s_b`, + // `s_c` and `s_d` tie at 300; `s_d` wins the tie on clickcount; `s_c` beats + // `s_b` on votes. This exercises every level of `_compararCalidad` and is + // what makes the extraction safe — a sort that silently sinks into + // transport would pass every other assertion below but fail this one. + List> fixtureDesordenada() => [ + { + 'stationuuid': 's_a', + 'name': 'A', + 'url_resolved': 'https://a.example/audio', + 'bitrate': 100, + 'clickcount': 10, + 'votes': 1, + }, + { + 'stationuuid': 's_b', + 'name': 'B', + 'url_resolved': 'https://b.example/audio', + 'bitrate': 300, + 'clickcount': 5, + 'votes': 50, + }, + { + 'stationuuid': 's_c', + 'name': 'C', + 'url_resolved': 'https://c.example/audio', + 'bitrate': 300, + 'clickcount': 5, + 'votes': 999, + }, + { + 'stationuuid': 's_d', + 'name': 'D', + 'url_resolved': 'https://d.example/audio', + 'bitrate': 300, + 'clickcount': 80, + 'votes': 1, + }, + { + 'stationuuid': 's_e', + 'name': 'E', + 'url_resolved': 'https://e.example/audio', + 'bitrate': 50, + 'clickcount': 999, + 'votes': 999, + }, + ]; + + const ordenEsperado = ['s_d', 's_c', 's_b', 's_a', 's_e']; + + /// Runs [accion] against a `ServicioRadio` whose single mock host always + /// answers with [fixtureDesordenada], and returns both the captured + /// request and the method's return value so callers can assert transport + /// concerns (path/params/headers) and result ordering together. + Future<(http.Request, List)> ejecutar( + Future> Function(ServicioRadio) accion, + ) async { + late http.Request solicitud; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + solicitud = request; + return http.Response( + jsonEncode(fixtureDesordenada()), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + servidores: const ['host.api.radio-browser.info'], + retryDelay: Duration.zero, + ); + final resultado = await accion(servicio); + return (solicitud, resultado); + } + + group('ServicioRadio — 7 métodos basados en _get', () { + test('obtenerPopulares: path, filtros de transporte y orden', () async { + final (solicitud, emisoras) = await ejecutar( + (s) => s.obtenerPopulares(limit: 7, offset: 3), + ); + + expect(solicitud.url.path, '/json/stations/search'); + expect(solicitud.url.queryParameters['lastcheckok'], '1'); + expect(solicitud.url.queryParameters['hidebroken'], 'true'); + expect(solicitud.url.queryParameters['order'], 'bitrate'); + expect(solicitud.url.queryParameters['reverse'], 'true'); + expect(solicitud.url.queryParameters['limit'], '7'); + expect(solicitud.url.queryParameters['offset'], '3'); + expect(solicitud.headers['User-Agent'], isNotEmpty); + expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); + }); + + test('obtenerTendencias: path con limit embebido, sin order/reverse, ' + 'filtros de transporte y orden', () async { + final (solicitud, emisoras) = await ejecutar( + (s) => s.obtenerTendencias(limit: 12), + ); + + expect(solicitud.url.path, '/json/stations/topclick/12'); + expect(solicitud.url.queryParameters['lastcheckok'], '1'); + expect(solicitud.url.queryParameters['hidebroken'], 'true'); + // topclick is pre-sorted by the endpoint itself — no order/reverse + // is ever sent for this method. + expect(solicitud.url.queryParameters.containsKey('order'), isFalse); + expect(solicitud.url.queryParameters.containsKey('reverse'), isFalse); + expect(solicitud.headers['User-Agent'], isNotEmpty); + expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); + }); + + test('buscarPorNombre: path, filtros de transporte y orden', () async { + final (solicitud, emisoras) = await ejecutar( + (s) => s.buscarPorNombre('radio horizonte', limit: 9, offset: 2), + ); + + expect(solicitud.url.path, '/json/stations/search'); + expect(solicitud.url.queryParameters['name'], 'radio horizonte'); + expect(solicitud.url.queryParameters['lastcheckok'], '1'); + expect(solicitud.url.queryParameters['hidebroken'], 'true'); + expect(solicitud.url.queryParameters['order'], 'bitrate'); + expect(solicitud.url.queryParameters['reverse'], 'true'); + expect(solicitud.url.queryParameters['limit'], '9'); + expect(solicitud.url.queryParameters['offset'], '2'); + expect(solicitud.headers['User-Agent'], isNotEmpty); + expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); + }); + + test('buscarPorPais: path, filtros de transporte y orden', () async { + final (solicitud, emisoras) = await ejecutar( + (s) => s.buscarPorPais('ES', limit: 11, offset: 4), + ); + + expect(solicitud.url.path, '/json/stations/bycountrycodeexact/ES'); + expect(solicitud.url.queryParameters['lastcheckok'], '1'); + expect(solicitud.url.queryParameters['hidebroken'], 'true'); + expect(solicitud.url.queryParameters['order'], 'bitrate'); + expect(solicitud.url.queryParameters['reverse'], 'true'); + expect(solicitud.url.queryParameters['limit'], '11'); + expect(solicitud.url.queryParameters['offset'], '4'); + expect(solicitud.headers['User-Agent'], isNotEmpty); + expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); + }); + + test('buscarPorIdioma: path, filtros de transporte y orden', () async { + final (solicitud, emisoras) = await ejecutar( + (s) => s.buscarPorIdioma('spanish', limit: 6, offset: 1), + ); + + expect(solicitud.url.path, '/json/stations/bylanguageexact/spanish'); + expect(solicitud.url.queryParameters['lastcheckok'], '1'); + expect(solicitud.url.queryParameters['hidebroken'], 'true'); + expect(solicitud.url.queryParameters['order'], 'bitrate'); + expect(solicitud.url.queryParameters['reverse'], 'true'); + expect(solicitud.url.queryParameters['limit'], '6'); + expect(solicitud.url.queryParameters['offset'], '1'); + expect(solicitud.headers['User-Agent'], isNotEmpty); + expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); + }); + + test('buscarPorTag: path, filtros de transporte y orden', () async { + final (solicitud, emisoras) = await ejecutar( + (s) => s.buscarPorTag('jazz', limit: 8, offset: 0), + ); + + expect(solicitud.url.path, '/json/stations/bytagexact/jazz'); + expect(solicitud.url.queryParameters['lastcheckok'], '1'); + expect(solicitud.url.queryParameters['hidebroken'], 'true'); + expect(solicitud.url.queryParameters['order'], 'bitrate'); + expect(solicitud.url.queryParameters['reverse'], 'true'); + expect(solicitud.url.queryParameters['limit'], '8'); + expect(solicitud.url.queryParameters['offset'], '0'); + expect(solicitud.headers['User-Agent'], isNotEmpty); + expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); + }); + + test('buscar: combina name/countrycode/language/tag, filtros de transporte ' + 'y orden', () async { + final (solicitud, emisoras) = await ejecutar( + (s) => s.buscar( + nombre: 'rock', + pais: 'ES', + idioma: 'spanish', + tag: 'indie', + limit: 15, + offset: 5, + ), + ); + + expect(solicitud.url.path, '/json/stations/search'); + expect(solicitud.url.queryParameters['name'], 'rock'); + expect(solicitud.url.queryParameters['countrycode'], 'ES'); + expect(solicitud.url.queryParameters['language'], 'spanish'); + expect(solicitud.url.queryParameters['tag'], 'indie'); + expect(solicitud.url.queryParameters['lastcheckok'], '1'); + expect(solicitud.url.queryParameters['hidebroken'], 'true'); + expect(solicitud.url.queryParameters['order'], 'bitrate'); + expect(solicitud.url.queryParameters['reverse'], 'true'); + expect(solicitud.url.queryParameters['limit'], '15'); + expect(solicitud.url.queryParameters['offset'], '5'); + expect(solicitud.headers['User-Agent'], isNotEmpty); + expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); + }); + }); + + group('ServicioRadio — obtenerPaises (usa _getJson, no _get)', () { + test('consulta /json/countries sin lastcheckok ni order; conserva ' + 'hidebroken; parsea stationcount desde un string JSON', () async { + late http.Request solicitud; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + solicitud = request; + return http.Response( + jsonEncode([ + {'name': 'Spain', 'iso_3166_1': 'es', 'stationcount': '482'}, + {'name': 'Argentina', 'iso_3166_1': 'ar', 'stationcount': '120'}, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + servidores: const ['host.api.radio-browser.info'], + retryDelay: Duration.zero, + ); + + final paises = await servicio.obtenerPaises(); + + expect(solicitud.url.path, '/json/countries'); + // The station-only filter and the station-only sort must NOT reach + // this endpoint — that is the entire point of the `_getJson` + // extraction (spec scenario "Countries request omits the + // station-only filter"). + expect(solicitud.url.queryParameters.containsKey('lastcheckok'), isFalse); + expect(solicitud.url.queryParameters.containsKey('order'), isFalse); + expect(solicitud.url.queryParameters.containsKey('reverse'), isFalse); + // Inherited from `_uri`, unchanged — desirable here too (Engram id + // 2500): the endpoint's own default is `false`. + expect(solicitud.url.queryParameters['hidebroken'], 'true'); + expect(solicitud.headers['User-Agent'], isNotEmpty); + + expect(paises.map((p) => p.codigoIso), ['ES', 'AR']); + // `stationcount` arrived as a JSON STRING ("482") — this proves the + // whole pipeline (not just `PaisRadio.fromApi` in isolation) parses + // it without a cast error. + expect(paises.map((p) => p.numeroEmisoras), [482, 120]); + }); + }); + + group('ServicioRadio — registrarClick (8º, no usa _get)', () { + test( + 'pega al path correcto con un User-Agent no vacío ' + '(literal desactualizado, fuera de alcance: no se fija su valor)', + () async { + late http.Request solicitud; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + solicitud = request; + return http.Response('', 200); + }), + servidores: const ['host.api.radio-browser.info'], + retryDelay: Duration.zero, + ); + + await servicio.registrarClick('uuid-click'); + + expect(solicitud.url.path, '/json/url/uuid-click'); + expect(solicitud.headers['User-Agent'], isNotEmpty); + // Deliberately NOT asserting the exact User-Agent value: it hardcodes + // a stale 'PluriWave/0.1.0 (...)' literal (known, out-of-scope + // defect — see `_resolverUserAgent()`'s own doc comment). Pinning a + // known-wrong value would convert a bug into a contract. + }, + ); + }); +}