The backup envelope carried favorites, EQ presets, alarms and the multi-device toggle but not EstadoEcualizador's own on/off flag, so restoring a backup on another device silently kept that device's existing toggle state instead of the source device's. Bumps the backup schema to v4 (additive over v3): the flag is only written when explicitly provided, so old exports stay at v2/v3. Importing an old backup without the field leaves the current toggle untouched rather than defaulting it. Applying the imported value reuses EstadoEcualizador.cambiarActivo so it persists and pushes to the live audio engine exactly like a manual toggle.
760 lines
24 KiB
Dart
760 lines
24 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);
|
|
_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;
|
|
|
|
/// 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);
|
|
}
|