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.
798 lines
26 KiB
Dart
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);
|
|
}
|