Files
pluriwave/test/helpers/fakes.dart
T
FreeTLab 8a71bc237f feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Dos fallos reportados desde el uso real en el coche.

Los grupos de favoritos no volvian al importar

La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la
asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo
estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar,
que fuerza "sin asignar" a proposito, porque una emisora recien marcada como
favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de
restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se
separa la ruta de restaurar, que respeta el grupo_id del backup y su orden.

Ningun test lo detectaba porque el doble de pruebas era infiel: el fake
conservaba el grupo que la implementacion real destruia, y no tenia
restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo
esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble.

No hace falta subir la version del formato: el envoltorio ya llevaba todo.

El coche perdia la lista al reconectar

El contexto de reproduccion vivia solo en memoria, y el motor que arranca
Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al
reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y
siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se
persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve
contra las listas vivas en cada salto: si el grupo cambia de contenido entre
sesiones, el coche ve lo actual y no una foto vieja.

Cadena de repliegue, decidida por el propietario:
  grupo vivo con la emisora dentro  -> se recorre el grupo
  grupo vivo sin la emisora         -> se permanece en el grupo, primera
  grupo vivo pero vacio             -> se ensancha a favoritos
  grupo borrado                     -> favoritos
  sin favoritos                     -> comportamiento actual

Honrar un grupo de una sola emisora exigia levantar dos barreras, no una:
el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con
menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a
ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora
se salio del grupo" se trata en el flujo del salto.

La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo
solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta
en vez de recorrerse.

Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-04 13:26:00 +02:00

798 lines
26 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_presets_personalizados.dart';
import 'package:pluriwave/servicios/servicio_radio.dart';
class FakeServicioAudio extends ServicioAudio {
FakeServicioAudio({this.ecualizadorDisponible = true}) {
_estadoController.add(EstadoReproduccion.detenido);
}
/// Whether the native equalizer is available on this device — NOT whether
/// it is currently switched on (see [ecualizadorActivo] for that).
@override
final bool ecualizadorDisponible;
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;
/// Mirrors `PluriWaveAudioHandler._ecualizadorActivo`/`_presetActual`:
/// the handler-side EQ state, settable independently of the
/// `ServicioAudio`-forwarded methods below so tests can simulate a
/// car/notification-initiated change (eq-sync-superficies).
bool _ecualizadorActivoValor = true;
PresetEcualizador _presetActualValor = PresetEcualizador.flat;
@override
bool get ecualizadorActivo => _ecualizadorActivoValor;
@override
PresetEcualizador get presetActual => _presetActualValor;
/// Simulates a car/notification-initiated EQ change: mutates the (fake)
/// handler's own state directly, the same way
/// `PluriWaveAudioHandler.customAction`/`seleccionarPresetEqPorMediaId`
/// call `setEcualizadorActivo`/`aplicarPreset` on the handler WITHOUT
/// going through `ServicioAudio` — then re-emits the current playback
/// state, mirroring `_actualizarControlesEq()`'s unconditional
/// `playbackState.add(...)` republish so a resync listener on
/// [estadoStream] picks it up. Deliberately does NOT append to
/// [cambiosEcualizadorActivo]/[presetsAplicados]: those track calls that
/// arrived through the `ServicioAudio`-forwarded (UI-initiated) path.
void simularCambioEqDesdeHandler({bool? activo, PresetEcualizador? preset}) {
if (activo != null) _ecualizadorActivoValor = activo;
if (preset != null) _presetActualValor = preset;
emitirEstado(_estadoActual);
}
@override
void configurarLocalizaciones(AppLocalizations l10n) {
// No global handler in tests; just record the call.
configuracionesL10n++;
}
@override
Emisora? get emisoraActual => _emisoraActual;
@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);
}
// WU14: the real ServicioAudio.togglePlay() reads `_handler.playbackState`
// (a real just_audio-backed handler that requires registrarHandler(), same
// gap already documented for androidAudioSessionIdStream above) — unsafe
// against a bare FakeServicioAudio. Overridden here using only this Fake's
// own state machinery so `pantalla_reproductor.dart`'s play/pause control
// (previously untested) can be exercised safely.
@override
Future<void> togglePlay() async {
if (_estadoActual == EstadoReproduccion.reproduciendo) {
await pausar();
} else {
emitirEstado(EstadoReproduccion.reproduciendo);
}
}
@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);
_presetActualValor = preset;
}
@override
Future<void> setBanda(int index, double db) async {}
@override
Future<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo);
_ecualizadorActivoValor = 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);
// FIEL a producción (`ServicioFavoritos.agregar`): esta es la primitiva de
// «marcar como favorita», y fuerza `sin_asignar` además de un `orden`
// nuevo. El doble NO lo hacía, así que cualquier test de import escrito
// contra él salía verde mientras el dispositivo real perdía la asignación
// de grupo. Para RESTAURAR una copia existe `restaurarFavorito`.
_favoritos.add(
emisora.copyWith(
orden: _favoritos.length,
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
),
);
}
@override
Future<void> restaurarFavorito(Emisora emisora) async {
// Fiel a producción: preserva `orden` y `grupo_id`, cayendo a
// `sin_asignar` cuando el grupo de la copia no existe.
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
final existe = _grupos.any((g) => g.id == emisora.grupoFavoritosId);
_favoritos.add(
existe
? emisora
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId),
);
}
@override
Future<void> restaurarGrupo(GrupoFavoritos grupo) async {
// Sin este override la llamada caía en la implementación REAL de sqflite
// y explotaba con «databaseFactory not initialized»; solo pasaba
// desapercibido porque todos los tests de import existentes mandaban
// `gruposFavoritos: []`.
if (grupo.esSinAsignar) return;
final index = _grupos.indexWhere((g) => g.id == grupo.id);
if (index == -1) {
_grupos.add(grupo);
} else {
_grupos[index] = grupo;
}
}
@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;
/// Number of times [guardarActivo] has been called — lets tests assert a
/// persistence write happened exactly once (eq-sync-superficies).
int guardarActivoLlamadas = 0;
@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 {
guardarActivoLlamadas++;
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,
activo: activo,
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
presetsDispositivo: _config.presetsDispositivo,
presetsMatriz: _config.presetsMatriz,
nombresDispositivos: _config.nombresDispositivos,
);
}
/// Overridden like every other write below: without this, calls fall
/// through to the real `ServicioEcualizador.guardarConfiguracion`, which
/// hits real SharedPreferences and needs a Flutter test binding — a
/// footgun for any test that reaches `EstadoEcualizador.importarConfiguracion`
/// (the backup-import path) through this Fake without also wiring
/// `TestWidgetsFlutterBinding`/mock prefs.
@override
Future<void> guardarConfiguracion(ConfiguracionEcualizador config) async {
_config = config;
}
@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,
);
}
}
/// In-memory fake for [ServicioPresetsPersonalizados] (WU13). Avoids any
/// real SharedPreferences I/O in state-layer tests, matching every other
/// `Fake*` service in this file.
class FakeServicioPresetsPersonalizados extends ServicioPresetsPersonalizados {
final List<PresetEcualizador> _presets = [];
@override
Future<List<PresetEcualizador>> listar() async => List.from(_presets);
@override
Future<void> guardar(PresetEcualizador preset) async {
_presets.removeWhere((p) => p.nombre == preset.nombre);
_presets.add(preset);
}
@override
Future<void> eliminar(String nombre) async {
_presets.removeWhere((p) => p.nombre == nombre);
}
}
/// 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();
}
/// WU14: a recording fake that actually responds to `iniciar`/`detener`
/// in-memory, never touching real files or platform channels (`iniciar` on
/// the real `ServicioGrabacionRadio` opens an HTTP stream to the station's
/// URL and writes to disk — unsafe inside a widget test). Records every
/// call for assertions.
class FakeServicioGrabacionRadioActivable extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva();
final List<Duration?> duracionesIniciadas = [];
Emisora? ultimaEmisoraIniciada;
int detenerCalls = 0;
@override
EstadoGrabacionRadio get estado => _estadoActual;
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<void> iniciar(
Emisora emisora, {
Duration? duracion,
String? directorio,
}) async {
ultimaEmisoraIniciada = emisora;
duracionesIniciadas.add(duracion);
_estadoActual = EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.grabando,
emisora: emisora,
inicio: DateTime.now(),
duracionObjetivo: duracion,
);
_controller.add(_estadoActual);
}
@override
Future<void> detener() async {
detenerCalls++;
_estadoActual = const EstadoGrabacionRadio.inactiva();
_controller.add(_estadoActual);
}
@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);
}