Files
pluriwave/test/helpers/fakes.dart
T
FreeTLab c6f16c81b5 feat(paises): add country browser and extract shared radio transport
Extracts ServicioRadio's transport loop (server discovery, host rotation,
bounded retries, User-Agent, timeout, status check, json.decode, sticky-host
bookkeeping) out of `_get` into a new `_getJson(path, params) ->
Future<List<dynamic>>` helper, moved as one block with no logic edits. `_get`
is reimplemented on top, still owning every station-specific concern:
`lastcheckok: '1'`, `Emisora.fromApi` + the empty-uuid/url filter, and the
`_compararCalidad` quality sort. `_getJson` is deliberately sort-agnostic and
filter-agnostic so a non-station endpoint can reuse the resilience behaviour
without inheriting station-only semantics.

Non-negotiable ordering followed per design ADR-4: new
test/servicios/servicio_radio_transporte_test.dart characterises all 8
existing station calls (7 via `_get` plus `registrarClick`, which builds its
own URI) against the UNMODIFIED `_get` first - green by construction -
pinning path, lastcheckok=1, hidebroken=true, a non-empty User-Agent, exact
order/reverse/limit/offset, and the exact returned UUID sequence from a
fixture with deliberately shuffled bitrate/clickcount/votes. That last
assertion is what makes the extraction safe: a sort that silently sank into
transport would pass every other check. Re-running the same file after the
extraction is byte-identical green. test/servicios/servicio_radio_test.dart
is untouched by this work unit - its passing unmodified is itself a signal
that transport wasn't disturbed.

The 6 pre-existing `order: bitrate` occurrences (obtenerPopulares,
buscarPorNombre, buscarPorPais, buscarPorIdioma, buscarPorTag, buscar) are
untouched - a deliberate server-side quality bias deciding which stations
return within `limit`, unrelated to and never to be confused with the
user-facing "Ordenar" control, which stays entirely client-side via the
existing OrdenEmisoras (Engram reference/radio-browser-sort-order).

Behaviour delta, accepted per ADR-4, not a regression: moving
`_servidorActual` bookkeeping into `_getJson` means a successful
`/json/countries` call now warms the sticky host for subsequent station
calls too - one shared warm mirror per instance, desirable, not per-call-type
state.

Adds the Paises browser over the verified `/json/countries` contract (Engram
reference/radio-browser-countries-endpoint): new lib/modelos/pais_radio.dart
(`PaisRadio.fromApi` parses `stationcount` via `int.tryParse` since the API
returns it as a JSON string, not an int - an `as int` cast would throw),
`obtenerPaises()` sends neither `lastcheckok` nor `order` (the screen sorts
client-side by name; the API's raw byte order isn't proper collation for any
locale this app ships), and inherits `hidebroken=true` from the unchanged
`_uri` (desirable here too, since the endpoint's own default is false).
`EstadoBusqueda` gains `paises`/`cargandoPaises`/`cargarPaises()` with an
in-memory cache guard so re-entering the screen never refetches.

New PantallaPaises (lib/pantallas/pantalla_paises.dart): a "Tus idiomas"
shortlist (one representative country per the app's 13 supported locales,
matched against the fetched list - the proposal/spec name this section but
don't specify its derivation) above the full alphabetical list, each entry
showing its parsed station count. Reachable from Buscar's discovery landing
state via a new entry row, added now rather than left dangling per this
file's own forward-reference comment (and the WU15/WU15b lesson: a
fully-tested but unreachable screen is a real defect, not a follow-up).

New ARB keys (en/es only, matching this change's established precedent):
countriesScreenTitle, countriesYourLanguagesTitle, countriesAllTitle,
radioCountriesError.

Tests: 631 -> 649 (2 skipped, unchanged). flutter analyze unchanged at 1
pre-existing info. grep confirms `countrycodes` appears nowhere in lib/.
2026-07-29 09:36:31 +02:00

627 lines
19 KiB
Dart

import 'dart:async';
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';
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 {
FakeServicioAudio({this.ecualizadorActivo = true}) {
_estadoController.add(EstadoReproduccion.detenido);
}
final bool ecualizadorActivo;
final _estadoController = StreamController<EstadoReproduccion>.broadcast();
final List<PresetEcualizador> presetsAplicados = [];
final List<Emisora> emisorasReproducidas = [];
final List<bool> cambiosEcualizadorActivo = [];
final List<double> volumenesAplicados = [];
int pausas = 0;
int configuracionesL10n = 0;
Emisora? _emisoraActual;
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
@override
void configurarLocalizaciones(AppLocalizations l10n) {
// No global handler in tests; just record the call.
configuracionesL10n++;
}
@override
Emisora? get emisoraActual => _emisoraActual;
@override
bool get ecualizadorDisponible => ecualizadorActivo;
@override
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
// WU5: the real getter needs registrarHandler() (main.dart, production
// only) — reading it against a bare FakeServicioAudio throws
// "registrarHandler() no fue llamado en main.dart". No test exercised
// this getter before PantallaInicio started wiring VisualizadorAudio to
// it (WU5's Escuchar hero, mirroring pantalla_reproductor.dart's own
// usage, which has no test file at all). An empty stream matches
// VisualizadorAudio's own documented no-native-session fallback.
@override
Stream<int?> get androidAudioSessionIdStream => const Stream<int?>.empty();
@override
bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo;
@override
Future<void> reproducir(Emisora emisora) async {
_emisoraActual = emisora;
emisorasReproducidas.add(emisora);
emitirEstado(EstadoReproduccion.reproduciendo);
}
@override
Future<void> detener() async {
_emisoraActual = null;
emitirEstado(EstadoReproduccion.detenido);
}
@override
Future<void> pausar() async {
pausas++;
emitirEstado(EstadoReproduccion.pausado);
}
@override
Future<void> setVolumen(double vol) async {
volumenesAplicados.add(vol);
}
void emitirEstado(EstadoReproduccion estado) {
_estadoActual = estado;
_estadoController.add(estado);
}
@override
Future<void> aplicarPreset(PresetEcualizador preset) async {
presetsAplicados.add(preset);
}
@override
Future<void> setBanda(int index, double db) async {}
@override
Future<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo);
}
@override
Future<void> dispose() async {
await _estadoController.close();
}
}
class FakeServicioFavoritos extends ServicioFavoritos {
final List<Emisora> _favoritos = [];
final List<GrupoFavoritos> _grupos = [
const GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
protegido: true,
),
];
int toggleCalls = 0;
@override
Future<List<Emisora>> obtenerTodos() async =>
_favoritos.map((e) => e.copyWith()).toList();
@override
Future<void> agregar(Emisora emisora) async {
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
_favoritos.add(emisora.copyWith(orden: _favoritos.length));
}
@override
Future<void> eliminar(String uuid) async {
_favoritos.removeWhere((e) => e.uuid == uuid);
for (var i = 0; i < _favoritos.length; i++) {
_favoritos[i] = _favoritos[i].copyWith(orden: i);
}
}
@override
Future<bool> esFavorito(String uuid) async =>
_favoritos.any((e) => e.uuid == uuid);
@override
Future<bool> toggleFavorito(Emisora emisora) async {
toggleCalls += 1;
if (_favoritos.any((e) => e.uuid == emisora.uuid)) {
await eliminar(emisora.uuid);
return false;
}
await agregar(emisora);
return true;
}
@override
Future<List<GrupoFavoritos>> obtenerGrupos() async =>
List.unmodifiable(_grupos);
@override
Future<GrupoFavoritos> crearGrupo(String nombre) async {
final grupo = GrupoFavoritos(
id: 'grupo_${_grupos.length}',
nombre: nombre.trim(),
orden: _grupos.length,
);
_grupos.add(grupo);
return grupo;
}
@override
Future<void> renombrarGrupo(String id, String nombre) async {
final index = _grupos.indexWhere((g) => g.id == id && !g.protegido);
if (index == -1) return;
_grupos[index] = _grupos[index].copyWith(nombre: nombre.trim());
}
@override
Future<void> eliminarGrupo(String id) async {
if (id == GrupoFavoritos.sinAsignarId) return;
_grupos.removeWhere((g) => g.id == id && !g.protegido);
for (var i = 0; i < _favoritos.length; i++) {
if (_favoritos[i].grupoFavoritosId == id) {
_favoritos[i] = _favoritos[i].copyWith(
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
);
}
}
}
@override
Future<void> asignarGrupo(String uuid, String grupoId) async {
final destino =
_grupos.any((g) => g.id == grupoId)
? grupoId
: GrupoFavoritos.sinAsignarId;
final index = _favoritos.indexWhere((e) => e.uuid == uuid);
if (index != -1) {
_favoritos[index] = _favoritos[index].copyWith(grupoFavoritosId: destino);
}
}
@override
Future<void> reordenar(String uuid, int nuevoOrden) async {
final oldIndex = _favoritos.indexWhere((e) => e.uuid == uuid);
if (oldIndex == -1 || _favoritos.isEmpty) return;
final targetIndex = nuevoOrden.clamp(0, _favoritos.length - 1);
final moved = _favoritos.removeAt(oldIndex);
_favoritos.insert(targetIndex, moved);
for (var i = 0; i < _favoritos.length; i++) {
_favoritos[i] = _favoritos[i].copyWith(orden: i);
}
}
}
class FakeServicioRadio extends ServicioRadio {
FakeServicioRadio({
List<Emisora>? populares,
List<Emisora>? tendencias,
List<Emisora>? busqueda,
List<List<Emisora>>? popularesPorLlamada,
List<List<Emisora>>? tendenciasPorLlamada,
List<Object>? erroresPopularesPorLlamada,
List<Object>? erroresTendenciasPorLlamada,
List<PaisRadio>? paises,
Object? errorPaises,
}) : _populares = populares ?? [],
_tendencias = tendencias ?? [],
_busqueda = busqueda ?? [],
_popularesPorLlamada = popularesPorLlamada ?? const [],
_tendenciasPorLlamada = tendenciasPorLlamada ?? const [],
_erroresPopularesPorLlamada = erroresPopularesPorLlamada ?? const [],
_erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const [],
_paises = paises ?? [],
_errorPaises = errorPaises;
final List<Emisora> _populares;
final List<Emisora> _tendencias;
final List<Emisora> _busqueda;
final List<List<Emisora>> _popularesPorLlamada;
final List<List<Emisora>> _tendenciasPorLlamada;
final List<Object> _erroresPopularesPorLlamada;
final List<Object> _erroresTendenciasPorLlamada;
final List<PaisRadio> _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) =>
error is Exception ? error : Exception(error.toString());
@override
Future<List<Emisora>> obtenerPopulares({
int limit = 30,
int offset = 0,
}) async {
final llamada = obtenerPopularesCalls++;
if (llamada < _erroresPopularesPorLlamada.length) {
throw _normalizarError(_erroresPopularesPorLlamada[llamada]);
}
final data =
llamada < _popularesPorLlamada.length
? _popularesPorLlamada[llamada]
: _populares;
return data.take(limit).toList();
}
@override
Future<List<Emisora>> obtenerTendencias({int limit = 20}) async {
final llamada = obtenerTendenciasCalls++;
if (llamada < _erroresTendenciasPorLlamada.length) {
throw _normalizarError(_erroresTendenciasPorLlamada[llamada]);
}
final data =
llamada < _tendenciasPorLlamada.length
? _tendenciasPorLlamada[llamada]
: _tendencias;
return data.take(limit).toList();
}
@override
Future<List<Emisora>> buscar({
String? nombre,
String? pais,
String? idioma,
String? tag,
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();
}
@override
Future<void> registrarClick(String uuid) async {
registrarClickCalls += 1;
ultimoUuidClick = uuid;
}
@override
Future<List<PaisRadio>> obtenerPaises() async {
obtenerPaisesCalls++;
final error = _errorPaises;
if (error != null) throw _normalizarError(error);
return _paises;
}
}
class FakeServicioEcualizador extends ServicioEcualizador {
FakeServicioEcualizador({
PresetEcualizador? principal,
Map<String, PresetEcualizador>? porEmisora,
bool activo = true,
bool eqMultiDeviceEnabled = false,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
Map<String, String>? nombresDispositivos,
}) : _config = ConfiguracionEcualizador(
principal: principal ?? PresetEcualizador.flat,
porEmisora: porEmisora ?? {},
activo: activo,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
presetsDispositivo: presetsDispositivo ?? {},
presetsMatriz: presetsMatriz ?? {},
nombresDispositivos: nombresDispositivos ?? {},
);
ConfiguracionEcualizador _config;
ConfiguracionEcualizador get config => _config;
@override
Future<ConfiguracionEcualizador> cargar() async => _config;
@override
Future<void> guardarPrincipal(PresetEcualizador preset) async {
_config = ConfiguracionEcualizador(
principal: preset,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
nombresDispositivos: _config.nombresDispositivos,
);
}
@override
Future<void> guardarActivo(bool activo) async {
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
nombresDispositivos: _config.nombresDispositivos,
);
}
@override
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
mapa[uuid] = preset;
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: mapa,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
);
}
@override
Future<void> eliminarPorEmisora(String uuid) async {
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
mapa.remove(uuid);
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: mapa,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
);
}
@override
Future<void> guardarToggleMultiDispositivo(bool habilitado) async {
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: habilitado,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
);
}
@override
Future<void> guardarPresetDispositivo(
String deviceId,
PresetEcualizador preset,
) async {
final mapa = Map<String, PresetEcualizador>.from(
_config.presetsDispositivo,
);
mapa[deviceId] = preset;
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: mapa,
presetsMatriz: _config.presetsMatriz,
);
}
@override
Future<void> guardarPresetMatriz(
String clave,
PresetEcualizador preset,
) async {
final mapa = Map<String, PresetEcualizador>.from(_config.presetsMatriz);
mapa[clave] = preset;
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: mapa,
nombresDispositivos: _config.nombresDispositivos,
);
}
@override
Future<void> guardarNombresDispositivos(Map<String, String> nombres) async {
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
nombresDispositivos: nombres,
);
}
@override
Future<void> eliminarDispositivo(String deviceId) async {
final presets = Map<String, PresetEcualizador>.from(
_config.presetsDispositivo,
)..remove(deviceId);
final nombres = Map<String, String>.from(_config.nombresDispositivos)
..remove(deviceId);
final matriz = Map<String, PresetEcualizador>.from(_config.presetsMatriz)
..removeWhere((clave, _) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1) == deviceId;
});
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: _config.activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: presets,
presetsMatriz: matriz,
nombresDispositivos: nombres,
);
}
}
/// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual].
///
/// Used to test the graceful-failure path in [EstadoEcualizador.cargarPersistido].
class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
final _controller = StreamController<DispositivoAudio>.broadcast();
@override
DispositivoAudio? get dispositivoActual => null;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
/// Number of times [resubscribir] was called.
int resubscribirLlamadas = 0;
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
throw Exception('Platform channel error: device unavailable');
}
@override
Future<Map<String, String>> obtenerNombresEmparejados() async {
throw Exception('Platform channel error: bonded devices unavailable');
}
@override
Future<void> resubscribir() async {
resubscribirLlamadas++;
throw Exception('Platform channel error: resubscribe unavailable');
}
@override
Future<bool> solicitarPermisoBluetooth() async => true;
@override
Future<void> dispose() async {
await _controller.close();
}
}
/// Fake implementation of [ServicioDispositivoAudio] for unit tests.
///
/// Use [emitirDispositivo] to push device change events without needing
/// a real platform channel.
class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
FakeServicioDispositivoAudio({
this.permisoBluetoothConcedido = true,
this.nombresEmparejados = const {},
});
/// MAC → name entries the system would report for PAIRED devices.
final Map<String, String> nombresEmparejados;
@override
Future<Map<String, String>> obtenerNombresEmparejados() async =>
nombresEmparejados;
/// Value returned by [solicitarPermisoBluetooth] (bt-device-identity
/// Phase 2/4 testing strategy — defaults to granted so existing tests
/// that don't care about the permission flow are unaffected).
final bool permisoBluetoothConcedido;
/// Number of times [solicitarPermisoBluetooth] was called.
int solicitarPermisoBluetoothCalls = 0;
/// Number of times [resubscribir] was called.
int resubscribirLlamadas = 0;
final _controller = StreamController<DispositivoAudio>.broadcast();
DispositivoAudio? _dispositivoActual;
@override
DispositivoAudio? get dispositivoActual => _dispositivoActual;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
return _dispositivoActual ??
const DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Speaker',
);
}
@override
Future<void> resubscribir() async {
resubscribirLlamadas++;
}
@override
Future<bool> solicitarPermisoBluetooth() async {
solicitarPermisoBluetoothCalls++;
return permisoBluetoothConcedido;
}
/// Helper for tests: push a device change event.
void emitirDispositivo(DispositivoAudio dispositivo) {
_dispositivoActual = dispositivo;
_controller.add(dispositivo);
}
/// Helper for tests: change the cached device WITHOUT emitting a stream
/// event — simulates the stale-subscription bug (activity recreation),
/// where the native device changed but the event never reached Dart and
/// only a fresh query ([obtenerDispositivoActual]) sees the new value.
void establecerDispositivoSinEvento(DispositivoAudio dispositivo) {
_dispositivoActual = dispositivo;
}
@override
Future<void> dispose() async {
await _controller.close();
}
}
/// 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,
String url = 'https://stream.demo/radio',
}) {
return Emisora(uuid: uuid, nombre: nombre, url: url);
}