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.broadcast(); final List presetsAplicados = []; final List emisorasReproducidas = []; final List cambiosEcualizadorActivo = []; final List 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 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 get androidAudioSessionIdStream => const Stream.empty(); @override bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo; @override Future reproducir(Emisora emisora) async { _emisoraActual = emisora; emisorasReproducidas.add(emisora); emitirEstado(EstadoReproduccion.reproduciendo); } @override Future detener() async { _emisoraActual = null; emitirEstado(EstadoReproduccion.detenido); } @override Future 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 togglePlay() async { if (_estadoActual == EstadoReproduccion.reproduciendo) { await pausar(); } else { emitirEstado(EstadoReproduccion.reproduciendo); } } @override Future setVolumen(double vol) async { volumenesAplicados.add(vol); } void emitirEstado(EstadoReproduccion estado) { _estadoActual = estado; _estadoController.add(estado); } @override Future aplicarPreset(PresetEcualizador preset) async { presetsAplicados.add(preset); _presetActualValor = preset; } @override Future setBanda(int index, double db) async {} @override Future setEcualizadorActivo(bool activo) async { cambiosEcualizadorActivo.add(activo); _ecualizadorActivoValor = activo; } @override Future dispose() async { await _estadoController.close(); } } class FakeServicioFavoritos extends ServicioFavoritos { final List _favoritos = []; final List _grupos = [ const GrupoFavoritos( id: GrupoFavoritos.sinAsignarId, nombre: 'Sin asignar', orden: 0, protegido: true, ), ]; int toggleCalls = 0; @override Future> obtenerTodos() async => _favoritos.map((e) => e.copyWith()).toList(); @override Future 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 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 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 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 esFavorito(String uuid) async => _favoritos.any((e) => e.uuid == uuid); @override Future 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> obtenerGrupos() async => List.unmodifiable(_grupos); @override Future crearGrupo(String nombre) async { final grupo = GrupoFavoritos( id: 'grupo_${_grupos.length}', nombre: nombre.trim(), orden: _grupos.length, ); _grupos.add(grupo); return grupo; } @override Future 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 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 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 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? populares, List? tendencias, List? busqueda, List>? popularesPorLlamada, List>? tendenciasPorLlamada, List? erroresPopularesPorLlamada, List? erroresTendenciasPorLlamada, List? paises, Object? errorPaises, }) : _populares = populares ?? [], _tendencias = tendencias ?? [], _busqueda = busqueda ?? [], _popularesPorLlamada = popularesPorLlamada ?? const [], _tendenciasPorLlamada = tendenciasPorLlamada ?? const [], _erroresPopularesPorLlamada = erroresPopularesPorLlamada ?? const [], _erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const [], _paises = paises ?? [], _errorPaises = errorPaises; final List _populares; final List _tendencias; final List _busqueda; final List> _popularesPorLlamada; final List> _tendenciasPorLlamada; final List _erroresPopularesPorLlamada; final List _erroresTendenciasPorLlamada; final List _paises; final Object? _errorPaises; int obtenerPopularesCalls = 0; int obtenerTendenciasCalls = 0; int registrarClickCalls = 0; int buscarCalls = 0; int obtenerPaisesCalls = 0; String? ultimoUuidClick; Exception _normalizarError(Object error) => error is Exception ? error : Exception(error.toString()); @override Future> 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> 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> 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 registrarClick(String uuid) async { registrarClickCalls += 1; ultimoUuidClick = uuid; } @override Future> obtenerPaises() async { obtenerPaisesCalls++; final error = _errorPaises; if (error != null) throw _normalizarError(error); return _paises; } } class FakeServicioEcualizador extends ServicioEcualizador { FakeServicioEcualizador({ PresetEcualizador? principal, Map? porEmisora, bool activo = true, bool eqMultiDeviceEnabled = false, Map? presetsDispositivo, Map? presetsMatriz, Map? 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 cargar() async => _config; @override Future 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 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 guardarConfiguracion(ConfiguracionEcualizador config) async { _config = config; } @override Future guardarPorEmisora(String uuid, PresetEcualizador preset) async { final mapa = Map.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 eliminarPorEmisora(String uuid) async { final mapa = Map.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 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 guardarPresetDispositivo( String deviceId, PresetEcualizador preset, ) async { final mapa = Map.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 guardarPresetMatriz( String clave, PresetEcualizador preset, ) async { final mapa = Map.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 guardarNombresDispositivos(Map 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 eliminarDispositivo(String deviceId) async { final presets = Map.from( _config.presetsDispositivo, )..remove(deviceId); final nombres = Map.from(_config.nombresDispositivos) ..remove(deviceId); final matriz = Map.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 _presets = []; @override Future> listar() async => List.from(_presets); @override Future guardar(PresetEcualizador preset) async { _presets.removeWhere((p) => p.nombre == preset.nombre); _presets.add(preset); } @override Future 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.broadcast(); @override DispositivoAudio? get dispositivoActual => null; @override Stream get onDispositivoCambiado => _controller.stream; /// Number of times [resubscribir] was called. int resubscribirLlamadas = 0; @override Future obtenerDispositivoActual() async { throw Exception('Platform channel error: device unavailable'); } @override Future> obtenerNombresEmparejados() async { throw Exception('Platform channel error: bonded devices unavailable'); } @override Future resubscribir() async { resubscribirLlamadas++; throw Exception('Platform channel error: resubscribe unavailable'); } @override Future solicitarPermisoBluetooth() async => true; @override Future 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 nombresEmparejados; @override Future> 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.broadcast(); DispositivoAudio? _dispositivoActual; @override DispositivoAudio? get dispositivoActual => _dispositivoActual; @override Stream get onDispositivoCambiado => _controller.stream; @override Future obtenerDispositivoActual() async { return _dispositivoActual ?? const DispositivoAudio( id: 'builtin_speaker', tipo: TipoDispositivo.altavozInterno, nombre: 'Speaker', ); } @override Future resubscribir() async { resubscribirLlamadas++; } @override Future 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 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.broadcast(); @override EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva(); @override Stream get estadoStream => _controller.stream; @override Future inicializar() async {} @override Future 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.broadcast(); EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva(); final List duracionesIniciadas = []; Emisora? ultimaEmisoraIniciada; int detenerCalls = 0; @override EstadoGrabacionRadio get estado => _estadoActual; @override Stream get estadoStream => _controller.stream; @override Future inicializar() async {} @override Future 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 detener() async { detenerCalls++; _estadoActual = const EstadoGrabacionRadio.inactiva(); _controller.add(_estadoActual); } @override Future 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); }