From d579a0e107e1dfff87498c753aef3b04fd0aa9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Bautista=20Fern=C3=A1ndez?= Date: Mon, 27 Apr 2026 17:34:04 +0200 Subject: [PATCH] feat: Implement startup retry mechanism for custom stations and equalizer persistence - Added state management for startup retry and custom station handling in `EstadoRadio`. - Created tasks for implementing strict TDD with RED tests for HTTP failure retries and EQ persistence. - Developed verification report to ensure compliance with TDD practices. - Introduced fake services for testing, including `FakeServicioAudio`, `FakeServicioFavoritos`, and `FakeServicioRadio`. - Implemented widget tests for `PantallaInicio` and `PantallaFavoritos` to validate UI behavior with custom stations. - Enhanced `ServicioRadio` to support host rotation and retry logic for API calls. - Established a new configuration file to enforce project constraints and testing rules. --- .gitignore | 1 + lib/estado/estado_radio.dart | 348 ++++++++++++++---- lib/modelos/preset_ecualizador.dart | 16 + lib/pantallas/pantalla_ajustes.dart | 45 ++- lib/pantallas/pantalla_inicio.dart | 2 +- lib/servicios/servicio_audio.dart | 46 +-- lib/servicios/servicio_ecualizador.dart | 104 ++++++ lib/servicios/servicio_radio.dart | 122 +++--- .../apply-progress.md | 49 +++ .../design.md | 86 +++++ .../explore.md | 69 ++++ .../proposal.md | 72 ++++ .../spec.md | 83 +++++ .../state.yaml | 48 +++ .../tasks.md | 39 ++ .../verify-report.md | 222 +++++++++++ openspec/config.yaml | 64 ++++ test/estado/estado_radio_test.dart | 184 +++++++++ test/helpers/fakes.dart | 213 +++++++++++ test/pantallas/pantalla_inicio_test.dart | 173 +++++++++ test/servicios/servicio_radio_test.dart | 72 ++++ 21 files changed, 1902 insertions(+), 156 deletions(-) create mode 100644 lib/servicios/servicio_ecualizador.dart create mode 100644 openspec/changes/startup-retry-custom-stations-eq-persistence/apply-progress.md create mode 100644 openspec/changes/startup-retry-custom-stations-eq-persistence/design.md create mode 100644 openspec/changes/startup-retry-custom-stations-eq-persistence/explore.md create mode 100644 openspec/changes/startup-retry-custom-stations-eq-persistence/proposal.md create mode 100644 openspec/changes/startup-retry-custom-stations-eq-persistence/spec.md create mode 100644 openspec/changes/startup-retry-custom-stations-eq-persistence/state.yaml create mode 100644 openspec/changes/startup-retry-custom-stations-eq-persistence/tasks.md create mode 100644 openspec/changes/startup-retry-custom-stations-eq-persistence/verify-report.md create mode 100644 openspec/config.yaml create mode 100644 test/estado/estado_radio_test.dart create mode 100644 test/helpers/fakes.dart create mode 100644 test/pantallas/pantalla_inicio_test.dart create mode 100644 test/servicios/servicio_radio_test.dart diff --git a/.gitignore b/.gitignore index 3820a95..9d28a3d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ migrate_working_dir/ .pub/ /build/ /coverage/ +.atl/ # Symbolication related app.*.symbols diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 2e70022..6008637 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -4,50 +4,121 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; + import '../modelos/emisora.dart'; import '../modelos/preset_ecualizador.dart'; import '../servicios/servicio_audio.dart'; +import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_favoritos.dart'; import '../servicios/servicio_radio.dart'; import '../servicios/servicio_timer.dart'; /// Estado global de la app con ChangeNotifier (Provider). class EstadoRadio extends ChangeNotifier { - final ServicioAudio audio = ServicioAudio(); - final ServicioFavoritos favoritos = ServicioFavoritos(); - final ServicioRadio radio = ServicioRadio(); - late final ServicioTimer timer; + EstadoRadio({ + ServicioAudio? audio, + ServicioFavoritos? favoritos, + ServicioRadio? radio, + ServicioEcualizador? servicioEcualizador, + Future Function()? resolverArchivoCustom, + bool iniciarAutomaticamente = true, + }) : audio = audio ?? ServicioAudio(), + favoritos = favoritos ?? ServicioFavoritos(), + radio = radio ?? ServicioRadio(), + servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(), + _resolverArchivoCustom = resolverArchivoCustom { + timer = ServicioTimer(this.audio); + _escucharErroresReproduccion(); + if (iniciarAutomaticamente) { + _initFuture = _init(); + } + } - // Errores de reproducción → SnackBar + final ServicioAudio audio; + final ServicioFavoritos favoritos; + final ServicioRadio radio; + final ServicioEcualizador servicioEcualizador; + final Future Function()? _resolverArchivoCustom; + + late final ServicioTimer timer; + StreamSubscription? _suscripcionEstadoAudio; + Future? _initFuture; + + // Errores de reproducción → SnackBar. final _errorController = StreamController.broadcast(); Stream get errorStream => _errorController.stream; List _populares = []; List _tendencias = []; List _resultadosBusqueda = []; - List _listafavoritos = []; + List _listaFavoritos = []; List _emisorasCustom = []; - // Presets EQ guardados por uuid de emisora + // Presets EQ guardados por uuid de emisora. final Map _presetsEmisoraMap = {}; + PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat; bool _cargandoPopulares = false; bool _cargandoBusqueda = false; String? _errorCarga; - EstadoRadio() { - timer = ServicioTimer(audio); - _init(); - _escucharErroresReproduccion(); + List get populares => _populares; + List get tendencias => _tendencias; + List get resultadosBusqueda => _resultadosBusqueda; + List get listaFavoritos => _listaFavoritos; + List get emisorasCustom => _emisorasCustom; + bool get cargandoPopulares => _cargandoPopulares; + bool get cargandoBusqueda => _cargandoBusqueda; + String? get error => _errorCarga; + Emisora? get emisoraActual => audio.emisoraActual; + Stream get estadoStream => audio.estadoStream; + PresetEcualizador get presetEcualizador => _presetActual; + PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal; + bool get ecualizadorDisponible => audio.ecualizadorDisponible; + + bool get emisoraActualEsFavorita { + final actual = emisoraActual; + if (actual == null) return false; + return _listaFavoritos.any((e) => e.uuid == actual.uuid); } - /// Escucha el stream de estado del audio y gestiona errores de reproducción - /// de forma centralizada: cancela el timer y notifica al usuario. + bool get emisoraActualTienePresetPropio { + final actual = emisoraActual; + if (actual == null) return false; + return tienePresetEcualizadorPorEmisora(actual.uuid); + } + + /// Lista principal (home): custom + populares, sin duplicados. + List get emisorasInicio { + final mapa = {}; + for (final emisora in _emisorasCustom) { + mapa[emisora.uuid] = emisora; + } + for (final emisora in _populares) { + mapa.putIfAbsent(emisora.uuid, () => emisora); + } + return mapa.values.toList(); + } + + Future inicializar() { + _initFuture ??= _init(); + return _initFuture!; + } + + Future _init() async { + await _cargarEcualizadorPersistido(); + await Future.wait([ + cargarPopulares(), + cargarFavoritos(), + _cargarEmisorasCustom(), + ]); + } + + /// Escucha el stream de estado del audio y gestiona errores de reproducción. void _escucharErroresReproduccion() { - audio.estadoStream.listen((estado) { + _suscripcionEstadoAudio = audio.estadoStream.listen((estado) { if (estado == EstadoReproduccion.error) { - // Cancelar el timer si estaba activo — no debe contar sin audio if (timer.activo) { timer.cancelar(); } @@ -56,25 +127,20 @@ class EstadoRadio extends ChangeNotifier { }); } - List get populares => _populares; - List get tendencias => _tendencias; - List get resultadosBusqueda => _resultadosBusqueda; - List get listaFavoritos => _listafavoritos; - List get emisorasCustom => _emisorasCustom; - bool get cargandoPopulares => _cargandoPopulares; - bool get cargandoBusqueda => _cargandoBusqueda; - String? get error => _errorCarga; - Emisora? get emisoraActual => audio.emisoraActual; - Stream get estadoStream => audio.estadoStream; - PresetEcualizador get presetEcualizador => _presetActual; - bool get ecualizadorDisponible => audio.ecualizadorDisponible; - - Future _init() async { - await Future.wait([ - cargarPopulares(), - cargarFavoritos(), - _cargarEmisoresCustom(), - ]); + Future _cargarEcualizadorPersistido() async { + try { + final config = await servicioEcualizador.cargar(); + _presetPrincipal = config.principal; + _presetActual = config.principal; + _presetsEmisoraMap + ..clear() + ..addAll(config.porEmisora); + await audio.aplicarPreset(_presetPrincipal); + } catch (_) { + _presetPrincipal = PresetEcualizador.flat; + _presetActual = PresetEcualizador.flat; + _presetsEmisoraMap.clear(); + } } Future cargarPopulares() async { @@ -88,7 +154,7 @@ class EstadoRadio extends ChangeNotifier { ]); _populares = results[0]; _tendencias = results[1]; - } catch (e) { + } catch (_) { _errorCarga = 'Sin conexión a la API de radio'; } finally { _cargandoPopulares = false; @@ -97,7 +163,7 @@ class EstadoRadio extends ChangeNotifier { } Future cargarFavoritos() async { - _listafavoritos = await favoritos.obtenerTodos(); + _listaFavoritos = await favoritos.obtenerTodos(); notifyListeners(); } @@ -117,7 +183,7 @@ class EstadoRadio extends ChangeNotifier { idioma: idioma, tag: tag, ); - } catch (e) { + } catch (_) { _errorController.add('Error en la búsqueda. Comprueba tu conexión.'); } finally { _cargandoBusqueda = false; @@ -128,18 +194,13 @@ class EstadoRadio extends ChangeNotifier { Future reproducir(Emisora emisora) async { try { await audio.reproducir(emisora); - radio.registrarClick(emisora.uuid); // fire & forget - // Restaurar preset del ecualizador de esta emisora - final preset = _presetsEmisoraMap[emisora.uuid] ?? PresetEcualizador.flat; - await cambiarPresetEcualizador(preset, guardPorEmisora: false); + unawaited(radio.registrarClick(emisora.uuid)); + await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid)); notifyListeners(); } catch (e) { - // La reproducción falló: cancelar el timer para evitar estado inconsistente - // (el timer no debe contar si no hay audio reproduciéndose) if (timer.activo) { timer.cancelar(); } - // Emitir mensaje claro al usuario con el nombre de la emisora final mensajeError = e.toString().replaceFirst('Exception: ', ''); _errorController.add( mensajeError.isNotEmpty && mensajeError != 'Exception' @@ -157,6 +218,9 @@ class EstadoRadio extends ChangeNotifier { Future toggleFavorito(Emisora emisora) async { final esFav = await favoritos.toggleFavorito(emisora); + if (!esFav) { + await deshabilitarPresetEcualizadorPorEmisora(emisora.uuid, notificar: false); + } await cargarFavoritos(); notifyListeners(); return esFav; @@ -164,43 +228,135 @@ class EstadoRadio extends ChangeNotifier { Future esFavorito(String uuid) => favoritos.esFavorito(uuid); - // ── Ecualizador ────────────────────────────────────────────────────────── + // ── Ecualizador ─────────────────────────────────────────────────────────── + + bool tienePresetEcualizadorPorEmisora(String uuid) => + _presetsEmisoraMap.containsKey(uuid); + + PresetEcualizador? presetEcualizadorPorEmisora(String uuid) => + _presetsEmisoraMap[uuid]; + + PresetEcualizador _presetParaEmisora(String uuid) => + _presetsEmisoraMap[uuid] ?? _presetPrincipal; + + Future _aplicarPresetActivo(PresetEcualizador preset) async { + _presetActual = preset; + await audio.aplicarPreset(preset); + } + + Future cambiarPresetPrincipalEcualizador( + PresetEcualizador preset, { + bool notificar = true, + }) async { + _presetPrincipal = preset; + await servicioEcualizador.guardarPrincipal(preset); + + final actual = emisoraActual; + final puedeAplicarAhora = + actual == null || !_presetsEmisoraMap.containsKey(actual.uuid); + if (puedeAplicarAhora) { + await _aplicarPresetActivo(preset); + } + + if (notificar) notifyListeners(); + } + + Future guardarPresetEcualizadorPorEmisora( + String uuid, + PresetEcualizador preset, { + bool notificar = true, + }) async { + _presetsEmisoraMap[uuid] = preset; + await servicioEcualizador.guardarPorEmisora(uuid, preset); + if (emisoraActual?.uuid == uuid) { + await _aplicarPresetActivo(preset); + } + if (notificar) notifyListeners(); + } + + Future habilitarPresetEcualizadorPorEmisora( + String uuid, { + PresetEcualizador? base, + bool notificar = true, + }) async { + final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal; + await guardarPresetEcualizadorPorEmisora( + uuid, + presetBase, + notificar: notificar, + ); + } + + Future deshabilitarPresetEcualizadorPorEmisora( + String uuid, { + bool notificar = true, + }) async { + _presetsEmisoraMap.remove(uuid); + await servicioEcualizador.eliminarPorEmisora(uuid); + if (emisoraActual?.uuid == uuid) { + await _aplicarPresetActivo(_presetPrincipal); + } + if (notificar) notifyListeners(); + } + + Future cambiarModoEcualizadorEmisoraActual({required bool usarPropio}) async { + final actual = emisoraActual; + if (actual == null) return; + if (usarPropio) { + await habilitarPresetEcualizadorPorEmisora(actual.uuid); + } else { + await deshabilitarPresetEcualizadorPorEmisora(actual.uuid); + } + } Future cambiarPresetEcualizador( PresetEcualizador preset, { - bool guardPorEmisora = true, + bool guardarPorEmisora = true, }) async { - _presetActual = preset; - await audio.aplicarPreset(preset); - if (guardPorEmisora && emisoraActual != null) { - _presetsEmisoraMap[emisoraActual!.uuid] = preset; + final actual = emisoraActual; + final usarPresetPropio = guardarPorEmisora && + actual != null && + _presetsEmisoraMap.containsKey(actual.uuid); + + if (usarPresetPropio) { + await guardarPresetEcualizadorPorEmisora(actual.uuid, preset); + return; } - notifyListeners(); + await cambiarPresetPrincipalEcualizador(preset); } Future cambiarBandaEcualizador(int index, double db) async { final bandas = List.from(_presetActual.bandas); - if (index >= 0 && index < bandas.length) bandas[index] = db; - _presetActual = PresetEcualizador(nombre: 'Personalizado', bandas: bandas); - await audio.setBanda(index, db); - if (emisoraActual != null) { - _presetsEmisoraMap[emisoraActual!.uuid] = _presetActual; - } - notifyListeners(); + if (index < 0 || index >= bandas.length) return; + + bandas[index] = db; + final modificado = PresetEcualizador( + nombre: 'Personalizado', + bandas: bandas, + ); + await cambiarPresetEcualizador(modificado); } // ── Emisoras personalizadas ─────────────────────────────────────────────── Future _archivoCustom() async { + if (_resolverArchivoCustom != null) { + return _resolverArchivoCustom!(); + } final dir = await getApplicationDocumentsDirectory(); return File('${dir.path}/emisoras_custom.json'); } - Future _cargarEmisoresCustom() async { + Future _cargarEmisorasCustom() async { try { - final f = await _archivoCustom(); - if (!await f.exists()) return; - final data = jsonDecode(await f.readAsString()) as List; + final archivo = await _archivoCustom(); + if (!await archivo.exists()) { + _emisorasCustom = []; + notifyListeners(); + return; + } + + final data = jsonDecode(await archivo.readAsString()) as List; _emisorasCustom = data .map((e) => Emisora.fromMap(Map.from(e as Map))) .toList(); @@ -210,24 +366,33 @@ class EstadoRadio extends ChangeNotifier { notifyListeners(); } - Future _guardarEmisoresCustom() async { - final f = await _archivoCustom(); - await f.writeAsString(jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList())); + Future _guardarEmisorasCustom() async { + final archivo = await _archivoCustom(); + await archivo.writeAsString( + jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()), + ); } - Future agregarEmitoraCustom(Emisora emisora) async { + Future agregarEmisoraCustom(Emisora emisora) async { _emisorasCustom.removeWhere((e) => e.uuid == emisora.uuid); _emisorasCustom.add(emisora); - await _guardarEmisoresCustom(); + await _guardarEmisorasCustom(); notifyListeners(); } - Future eliminarEmitoraCustom(String uuid) async { + // Compatibilidad con el nombre histórico (typo original). + Future agregarEmitoraCustom(Emisora emisora) => + agregarEmisoraCustom(emisora); + + Future eliminarEmisoraCustom(String uuid) async { _emisorasCustom.removeWhere((e) => e.uuid == uuid); - await _guardarEmisoresCustom(); + await _guardarEmisorasCustom(); notifyListeners(); } + // Compatibilidad con el nombre histórico (typo original). + Future eliminarEmitoraCustom(String uuid) => eliminarEmisoraCustom(uuid); + // ── Export / Import ─────────────────────────────────────────────────────── /// Genera el JSON de toda la configuración. @@ -238,6 +403,7 @@ class EstadoRadio extends ChangeNotifier { 'exportedAt': DateTime.now().toIso8601String(), 'favoritos': favs.map((e) => e.toMap()).toList(), 'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(), + 'presetPrincipalEcualizador': _presetPrincipal.toJson(), 'presetsEcualizador': _presetsEmisoraMap.map( (uuid, preset) => MapEntry(uuid, preset.toJson()), ), @@ -249,27 +415,52 @@ class EstadoRadio extends ChangeNotifier { final version = data['version'] as int? ?? 1; if (version != 1) throw Exception('Versión de configuración no compatible'); - // Importar favoritos final favRaw = data['favoritos'] as List? ?? []; for (final raw in favRaw) { final emisora = Emisora.fromMap(Map.from(raw as Map)); await favoritos.agregar(emisora); } - // Importar emisoras custom final customRaw = data['emisorasCustom'] as List? ?? []; _emisorasCustom = customRaw .map((e) => Emisora.fromMap(Map.from(e as Map))) .toList(); - await _guardarEmisoresCustom(); + await _guardarEmisorasCustom(); + + final principalRaw = data['presetPrincipalEcualizador']; + if (principalRaw is Map) { + _presetPrincipal = PresetEcualizador.desdeJson( + Map.from(principalRaw), + ); + } else { + _presetPrincipal = PresetEcualizador.flat; + } - // Importar presets EQ final presetsRaw = data['presetsEcualizador'] as Map? ?? {}; - _presetsEmisoraMap.clear(); - presetsRaw.forEach((uuid, presetJson) { - _presetsEmisoraMap[uuid as String] = - PresetEcualizador.desdeJson(Map.from(presetJson as Map)); - }); + _presetsEmisoraMap + ..clear() + ..addAll( + presetsRaw.map( + (uuid, presetJson) => MapEntry( + uuid as String, + PresetEcualizador.desdeJson( + Map.from(presetJson as Map), + ), + ), + ), + ); + + await servicioEcualizador.guardarConfiguracion( + ConfiguracionEcualizador( + principal: _presetPrincipal, + porEmisora: _presetsEmisoraMap, + ), + ); + + final actual = emisoraActual; + final presetActivo = + actual == null ? _presetPrincipal : _presetParaEmisora(actual.uuid); + await _aplicarPresetActivo(presetActivo); await cargarFavoritos(); notifyListeners(); @@ -289,6 +480,7 @@ class EstadoRadio extends ChangeNotifier { @override void dispose() { + _suscripcionEstadoAudio?.cancel(); _errorController.close(); audio.dispose(); timer.dispose(); diff --git a/lib/modelos/preset_ecualizador.dart b/lib/modelos/preset_ecualizador.dart index 147b0d7..4c4e790 100644 --- a/lib/modelos/preset_ecualizador.dart +++ b/lib/modelos/preset_ecualizador.dart @@ -26,4 +26,20 @@ class PresetEcualizador { PresetEcualizador copyWithBandas(List bandas) => PresetEcualizador(nombre: 'Personalizado', bandas: bandas); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! PresetEcualizador) return false; + if (nombre != other.nombre || bandas.length != other.bandas.length) { + return false; + } + for (int i = 0; i < bandas.length; i++) { + if (bandas[i] != other.bandas[i]) return false; + } + return true; + } + + @override + int get hashCode => Object.hash(nombre, Object.hashAll(bandas)); } diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 44b9813..e8a7399 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -44,6 +44,11 @@ class _SeccionEcualizador extends StatelessWidget { return Consumer( builder: (ctx, estado, _) { final disponible = estado.ecualizadorDisponible; + final emisoraActual = estado.emisoraActual; + final mostrarModoPorEmisora = + emisoraActual != null && estado.emisoraActualEsFavorita; + final usandoEqPropio = estado.emisoraActualTienePresetPropio; + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -57,27 +62,41 @@ class _SeccionEcualizador extends StatelessWidget { const Spacer(), if (!disponible) Chip( - label: const Text('Reproduce una emisora para activar'), + label: const Text('Se guarda aunque no esté activo'), visualDensity: VisualDensity.compact, ), ], ), - if (disponible) ...[ + if (mostrarModoPorEmisora) ...[ const SizedBox(height: 8), - PresetsEcualizadorWidget( - presetActual: estado.presetEcualizador, - onSeleccionar: (p) => estado.cambiarPresetEcualizador(p), - ), - const SizedBox(height: 12), - EcualizadorWidget( - preset: estado.presetEcualizador, - onCambio: (p) { - for (int i = 0; i < p.bandas.length; i++) { - estado.cambiarBandaEcualizador(i, p.bandas[i]); - } + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Usar EQ propio para esta favorita'), + subtitle: Text( + usandoEqPropio + ? 'Activo para ${emisoraActual.nombre}' + : 'Usando EQ principal para ${emisoraActual.nombre}', + ), + value: usandoEqPropio, + onChanged: (usarPropio) { + estado.cambiarModoEcualizadorEmisoraActual( + usarPropio: usarPropio, + ); }, ), ], + const SizedBox(height: 8), + PresetsEcualizadorWidget( + presetActual: estado.presetEcualizador, + onSeleccionar: (p) => estado.cambiarPresetEcualizador(p), + ), + const SizedBox(height: 12), + EcualizadorWidget( + preset: estado.presetEcualizador, + onCambio: (p) { + estado.cambiarPresetEcualizador(p); + }, + ), ], ), ); diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index 80f1403..baf6ce7 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -146,7 +146,7 @@ class _PantallaInicioState extends State { Widget _gridEmisoras(EstadoRadio estado, ThemeData theme) { final emisoras = _generoSeleccionado != null ? estado.resultadosBusqueda - : estado.populares; + : estado.emisorasInicio; final cargando = estado.cargandoPopulares || (_generoSeleccionado != null && estado.cargandoBusqueda); diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index d1f2c3d..633801e 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -2,15 +2,16 @@ import 'dart:developer' as developer; import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; + import '../modelos/emisora.dart'; import '../modelos/preset_ecualizador.dart'; /// Estado de reproducción expuesto al UI. enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error } -// ───────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // Handler global — inicializado en main.dart con AudioService.init -// ───────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── PluriWaveAudioHandler? _handlerGlobal; void registrarHandler(PluriWaveAudioHandler handler) { @@ -73,7 +74,7 @@ class ServicioAudio { bool get estaSonando => _handler.playbackState.value.playing; Future dispose() async {} - // ── Ecualizador ────────────────────────────────────────────────────────── + // ── Ecualizador ─────────────────────────────────────────────────────────── AndroidEqualizer? get ecualizador => _handler.ecualizador; bool get ecualizadorDisponible => _handler.ecualizadorDisponible; PresetEcualizador get presetActual => _handler.presetActual; @@ -85,9 +86,9 @@ class ServicioAudio { _handler.setBanda(index, db); } -// ───────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // AudioHandler -// ───────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { final AndroidEqualizer _eq = AndroidEqualizer(); @@ -132,9 +133,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { playbackState.add(playbackState.value.copyWith(bufferedPosition: pos)); }); - // ── Escuchar errores de ExoPlayer ───────────────────────────────────── - // Captura todos los PlaybackException: TYPE_SOURCE (HTTP cleartext, - // certificado inválido, 404), TYPE_UNEXPECTED, timeout de conexión, etc. _player.playbackEventStream.listen( (_) {}, onError: (Object error, StackTrace stackTrace) { @@ -143,8 +141,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { ); } - /// Gestiona cualquier error de reproducción de ExoPlayer de forma - /// controlada: emite estado de error al UI y resetea la reproducción. + /// Gestiona cualquier error de reproducción de ExoPlayer. void _gestionarErrorReproduccion(Object error) { String mensaje; String codigoLog; @@ -160,17 +157,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { developer.log( '[PluriWave] Error reproducción: $codigoLog', name: 'ServicioAudio', - level: 900, // warning + level: 900, ); - // Emitir estado de error al UI (incluye mensaje legible) playbackState.add(playbackState.value.copyWith( processingState: AudioProcessingState.error, playing: false, errorMessage: mensaje, )); + emisoraActual = null; + mediaItem.add(null); - // Resetear el player a estado idle limpio (sin lanzar otra excepción) _player.stop().catchError((_) {}); } @@ -178,7 +175,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { String _mensajeAmigable(PlayerException e) { final code = e.code; - // ERROR_CODE_IO_* — problemas de red/fuente if (code >= 2000 && code < 3000) { if (code == 2001) return 'Sin conexión a internet'; if (code == 2002) return 'La URL de la radio no es válida'; @@ -187,18 +183,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { return 'No se puede conectar a la radio'; } - // ERROR_CODE_PARSING_* — formato de stream no soportado if (code >= 3000 && code < 4000) { return 'Formato de stream no compatible'; } - // ERROR_CODE_DECODING_* — error de decodificación if (code >= 4000 && code < 5000) { return 'Error al decodificar el stream de audio'; } - // TYPE_SOURCE — error en la fuente (HTTP cleartext, cert, etc.) - // En just_audio suele mapearse como code=-1 o message con "Cleartext" final msg = e.message ?? ''; if (msg.contains('Cleartext') || msg.contains('cleartext')) { return 'Esta radio usa HTTP sin cifrar (no permitido)'; @@ -227,12 +219,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { await _player.stop(); await _player.setUrl(mediaItem.id); await _player.play(); - // Habilitar ecualizador tras reproducir (necesita audio activo) + emisoraActual = _emisoraDesdeMediaItem(mediaItem); await _activarEcualizador(); } on PlayerException catch (e) { - // El error ya llega por playbackEventStream.onError, pero también - // lo capturamos aquí para asegurarnos de emitir el estado de error - // y propagarlo como excepción (para que EstadoRadio muestre el mensaje). _gestionarErrorReproduccion(e); throw Exception(_mensajeAmigable(e)); } on Exception catch (e) { @@ -246,6 +235,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { playing: false, errorMessage: 'Error inesperado al reproducir', )); + emisoraActual = null; + this.mediaItem.add(null); rethrow; } } @@ -318,4 +309,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { await stop(); await _player.dispose(); } + + Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { + final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id; + return Emisora( + uuid: uuid, + nombre: mediaItem.title, + url: mediaItem.id, + pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null, + favicon: mediaItem.artUri?.toString(), + ); + } } diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart new file mode 100644 index 0000000..cd93bfa --- /dev/null +++ b/lib/servicios/servicio_ecualizador.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../modelos/preset_ecualizador.dart'; + +class ConfiguracionEcualizador { + const ConfiguracionEcualizador({ + required this.principal, + required this.porEmisora, + }); + + final PresetEcualizador principal; + final Map porEmisora; +} + +class ServicioEcualizador { + static const _keyPresetPrincipal = 'eq_preset_principal_v1'; + static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1'; + + Future cargar() async { + final prefs = await SharedPreferences.getInstance(); + final principal = _leerPresetPrincipal(prefs); + final porEmisora = _leerPresetsPorEmisora(prefs); + return ConfiguracionEcualizador( + principal: principal, + porEmisora: porEmisora, + ); + } + + Future guardarPrincipal(PresetEcualizador preset) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson())); + } + + Future guardarPorEmisora(String uuid, PresetEcualizador preset) async { + final prefs = await SharedPreferences.getInstance(); + final mapa = _leerPresetsPorEmisora(prefs); + mapa[uuid] = preset; + await _guardarPresetsPorEmisora(prefs, mapa); + } + + Future eliminarPorEmisora(String uuid) async { + final prefs = await SharedPreferences.getInstance(); + final mapa = _leerPresetsPorEmisora(prefs); + mapa.remove(uuid); + await _guardarPresetsPorEmisora(prefs, mapa); + } + + Future guardarConfiguracion(ConfiguracionEcualizador config) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _keyPresetPrincipal, + jsonEncode(config.principal.toJson()), + ); + await _guardarPresetsPorEmisora(prefs, config.porEmisora); + } + + PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) { + final raw = prefs.getString(_keyPresetPrincipal); + if (raw == null || raw.isEmpty) { + return PresetEcualizador.flat; + } + try { + return PresetEcualizador.desdeJson( + Map.from(jsonDecode(raw) as Map), + ); + } catch (_) { + return PresetEcualizador.flat; + } + } + + Map _leerPresetsPorEmisora( + SharedPreferences prefs, + ) { + final raw = prefs.getString(_keyPresetsPorEmisora); + if (raw == null || raw.isEmpty) { + return {}; + } + try { + final data = Map.from(jsonDecode(raw) as Map); + return data.map( + (uuid, preset) => MapEntry( + uuid, + PresetEcualizador.desdeJson( + Map.from(preset as Map), + ), + ), + ); + } catch (_) { + return {}; + } + } + + Future _guardarPresetsPorEmisora( + SharedPreferences prefs, + Map mapa, + ) async { + final serializado = mapa.map( + (uuid, preset) => MapEntry(uuid, preset.toJson()), + ); + await prefs.setString(_keyPresetsPorEmisora, jsonEncode(serializado)); + } +} diff --git a/lib/servicios/servicio_radio.dart b/lib/servicios/servicio_radio.dart index ddf0ef2..425871e 100644 --- a/lib/servicios/servicio_radio.dart +++ b/lib/servicios/servicio_radio.dart @@ -1,35 +1,58 @@ import 'dart:convert'; -import 'dart:math'; + import 'package:http/http.dart' as http; + import '../modelos/emisora.dart'; /// Cliente para la Radio Browser API (https://api.radio-browser.info/). /// -/// Selecciona automáticamente un servidor disponible de entre los DNS -/// resueltos para `all.api.radio-browser.info` y rota en caso de error. -/// -/// ### Rate limiting -/// La API no tiene límite documentado, pero por cortesía limitamos a -/// peticiones con `?limit` explícito y no hacemos polling automático. +/// Aplica reintentos acotados con rotación de host para tolerar fallos +/// transitorios al iniciar. class ServicioRadio { - static const _dnsHost = 'all.api.radio-browser.info'; - static const _timeout = Duration(seconds: 10); + static const _timeoutPorDefecto = Duration(seconds: 10); + static const _maxIntentosPorDefecto = 3; + static const _retryDelayPorDefecto = Duration(milliseconds: 250); - // Servidores conocidos como fallback si el DNS falla + // Servidores conocidos como fallback si el DNS falla. static const _servidoresFallback = [ 'de1.api.radio-browser.info', 'nl1.api.radio-browser.info', 'at1.api.radio-browser.info', ]; + ServicioRadio({ + http.Client? cliente, + List? servidores, + int maxIntentos = _maxIntentosPorDefecto, + Duration retryDelay = _retryDelayPorDefecto, + Duration timeout = _timeoutPorDefecto, + }) : _cliente = cliente ?? http.Client(), + _servidores = (servidores == null || servidores.isEmpty) + ? List.from(_servidoresFallback) + : List.from(servidores), + _maxIntentos = maxIntentos < 1 ? 1 : maxIntentos, + _retryDelay = retryDelay, + _timeout = timeout; + + final http.Client _cliente; + final List _servidores; + final int _maxIntentos; + final Duration _retryDelay; + final Duration _timeout; + String? _servidorActual; - Future _servidor() async { - if (_servidorActual != null) return _servidorActual!; - // Intentar DNS lookup simplificado — usamos fallback directamente - final servidores = List.from(_servidoresFallback)..shuffle(Random()); - _servidorActual = servidores.first; - return _servidorActual!; + int _indiceServidorInicial() { + if (_servidorActual == null) { + return 0; + } + final index = _servidores.indexOf(_servidorActual!); + return index >= 0 ? index : 0; + } + + String _servidorPorIntento(int indiceBase, int intento) { + final index = (indiceBase + intento) % _servidores.length; + return _servidores[index]; } Uri _uri(String servidor, String path, Map params) { @@ -40,31 +63,45 @@ class ServicioRadio { } Future> _get(String path, Map params) async { - final servidor = await _servidor(); - // lastcheckok=1 filtra emisoras que la API verificó como funcionales - final uri = _uri(servidor, path, { - 'lastcheckok': '1', - ...params, - }); - try { - final resp = await http.get(uri, headers: { - 'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)', - }).timeout(_timeout); + Exception? ultimoError; + final indiceBase = _indiceServidorInicial(); + final totalIntentos = _maxIntentos; - if (resp.statusCode != 200) { - throw Exception('API error ${resp.statusCode}'); + for (int intento = 0; intento < totalIntentos; intento++) { + final servidor = _servidorPorIntento(indiceBase, intento); + final uri = _uri(servidor, path, { + 'lastcheckok': '1', + ...params, + }); + + try { + final resp = await _cliente.get(uri, headers: { + 'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)', + }).timeout(_timeout); + + if (resp.statusCode != 200) { + throw Exception('API error ${resp.statusCode}'); + } + + final lista = json.decode(resp.body) as List; + _servidorActual = servidor; + return lista + .cast>() + .map(Emisora.fromApi) + .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) + .toList(); + } on Exception catch (e) { + ultimoError = e; + _servidorActual = null; + + final ultimoIntento = intento == (totalIntentos - 1); + if (!ultimoIntento && _retryDelay > Duration.zero) { + await Future.delayed(_retryDelay); + } } - final lista = json.decode(resp.body) as List; - return lista - .cast>() - .map(Emisora.fromApi) - .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) - .toList(); - } catch (e) { - // Rotar servidor en el siguiente intento - _servidorActual = null; - rethrow; } + + throw ultimoError ?? Exception('Error desconocido al consultar la API'); } /// Emisoras más votadas globalmente. @@ -133,16 +170,17 @@ class ServicioRadio { }); } - /// Registrar un click en la API (buenas prácticas de ciudadanía API). + /// Registrar un click en la API (best effort). Future registrarClick(String uuid) async { try { - final servidor = await _servidor(); - await http.get( + final servidor = + _servidorActual ?? _servidorPorIntento(_indiceServidorInicial(), 0); + await _cliente.get( Uri.https(servidor, '/json/url/$uuid'), headers: {'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)'}, ).timeout(_timeout); } catch (_) { - // No crítico — ignorar silenciosamente + // No crítico, ignorar. } } } diff --git a/openspec/changes/startup-retry-custom-stations-eq-persistence/apply-progress.md b/openspec/changes/startup-retry-custom-stations-eq-persistence/apply-progress.md new file mode 100644 index 0000000..7916b9e --- /dev/null +++ b/openspec/changes/startup-retry-custom-stations-eq-persistence/apply-progress.md @@ -0,0 +1,49 @@ +# Apply Progress - startup-retry-custom-stations-eq-persistence + +## Status + +- Phase: `apply-partial` +- Date: 2026-04-27 +- Strict TDD mode: active +- Runtime verification: blocked (`flutter` CLI is not available in PATH) + +## Fixes implemented for verify findings + +1. **CRITICAL: `mediaItem.add(null)` shadowing bug** + - Fixed in `lib/servicios/servicio_audio.dart` inside `playMediaItem(...)`. + - Replaced ambiguous `mediaItem.add(null)` with `this.mediaItem.add(null)` in the exception path. + +2. **WARNING: async EQ graph updates fired in loop** + - Fixed in `lib/pantallas/pantalla_ajustes.dart`. + - `EcualizadorWidget.onCambio` now calls one atomic state update (`estado.cambiarPresetEcualizador(p)`) instead of launching async updates per band in a loop. + +3. **CRITICAL: missing scenario tests** + - Expanded `test/estado/estado_radio_test.dart`: + - EQ state persists when native EQ is unavailable. + - Startup failure leaves visible error and manual reload recovers. + - Favorite without own EQ falls back to main EQ from first playback. + - Expanded `test/pantallas/pantalla_inicio_test.dart`: + - Custom station tap starts playback through `EstadoRadio.reproducir`. + - Manual retry button after startup failure triggers a new station load cycle. + - Favorites screen shows a custom station after favoriting + reload. + - Expanded `test/helpers/fakes.dart` with call counters and per-call failure/data sequencing for deterministic startup/retry tests. + +## TDD Cycle Evidence + +| Cycle | RED-first test intent | GREEN code change | Verification command | RED evidence | GREEN evidence | Result | +|---|---|---|---|---|---|---| +| 1 | Add missing tests for EQ unavailable, manual retry, custom playback tap, and favorites reload flows | Fix `servicio_audio.dart`, `pantalla_ajustes.dart`, and test fakes | `Get-Command flutter -ErrorAction SilentlyContinue` | `flutter` not found in PATH | Blocked (cannot execute tests) | BLOCKED | +| 2 | Keep new/updated tests in place and execute strict runner only | No further production changes after test additions | `flutter test` | Command attempted | `flutter` command not recognized | BLOCKED | + +## Validation commands run + +| Command | Result | +|---|---| +| `Get-Command flutter -ErrorAction SilentlyContinue` | `FLUTTER_NOT_FOUND` | +| `flutter test` | `CommandNotFoundException` (Flutter missing from PATH) | +| `flutter build` | Not run (prohibited) | + +## Remaining before verify can pass + +1. Install/expose Flutter in PATH for this environment. +2. Run `flutter test` and capture passing evidence for every scenario in `spec.md` (tasks 5.3 and 5.4). \ No newline at end of file diff --git a/openspec/changes/startup-retry-custom-stations-eq-persistence/design.md b/openspec/changes/startup-retry-custom-stations-eq-persistence/design.md new file mode 100644 index 0000000..6eaadf0 --- /dev/null +++ b/openspec/changes/startup-retry-custom-stations-eq-persistence/design.md @@ -0,0 +1,86 @@ +# Design: Startup retry, custom stations, and equalizer persistence + +## Technical Approach + +Keep the existing Provider/ChangeNotifier architecture. Add test seams first, then implement: radio retry in `ServicioRadio`, main-list composition in `EstadoRadio`, and EQ persistence in a dedicated `ServicioEcualizador` using SharedPreferences. Persisted Provider EQ state must not depend on Android equalizer availability. + +## Architecture Decisions + +| Decision | Choice | Alternatives | Rationale | +|---|---|---|---| +| API retry owner | `ServicioRadio._get()` owns bounded retry and host rotation | Retry only in `EstadoRadio` | Centralizes network resilience for startup and search; avoids duplicated policy. | +| Custom stations home UX | Add `EstadoRadio.emisorasInicio`/similar combining custom + popular when no genre filter | Separate home section | Minimal UI change and reuses `TarjetaEmisora` favorite behavior; separate section can come later. | +| EQ storage | New `ServicioEcualizador` with SharedPreferences JSON | SQLite migration in favorites DB | Main EQ is app-level, not favorite-row data; SharedPreferences is already available and simpler. | +| Station EQ fallback | `Map`; absent key means "use main" | Store explicit mode enum per station | Absence is compact and maps naturally to disabling own EQ. | +| Current station source | Fix `ServicioAudio`/handler to assign/clear `emisoraActual` | Track only in `EstadoRadio` | Existing mini player and player screen already read `audio.emisoraActual`; fix the actual source. | + +## Data Flow + +Startup: + + PluriWaveApp -> EstadoRadio._init() + -> ServicioEcualizador.cargar() -> preset principal + mapa por emisora + -> ServicioRadio.obtenerPopulares/Tendencias() -> retry/rotate host + -> ServicioFavoritos + emisoras_custom.json + +Playback: + + UI -> EstadoRadio.reproducir(emisora) + -> ServicioAudio.reproducir(emisora) sets current station + -> EstadoRadio selects EQ: presetsPorEmisora[uuid] ?? presetPrincipal + -> ServicioAudio.aplicarPreset(selected) + +EQ update: + + Settings/player EQ controls -> EstadoRadio + -> update main EQ OR station EQ + -> ServicioEcualizador.guardar(...) + -> ServicioAudio applies if available + +## File Changes + +| File | Action | Description | +|---|---|---| +| `lib/servicios/servicio_ecualizador.dart` | Create | Load/save main EQ and station EQ map from SharedPreferences. | +| `lib/servicios/servicio_radio.dart` | Modify | Add injectable client/config, retry count, backoff, and host rotation. | +| `lib/estado/estado_radio.dart` | Modify | Inject services, load EQ during init, expose combined main listing, apply fallback EQ policy. | +| `lib/servicios/servicio_audio.dart` | Modify | Assign/clear `emisoraActual`; keep default behavior backward-compatible. | +| `lib/pantallas/pantalla_inicio.dart` | Modify | Use combined main listing when no genre filter. | +| `lib/pantallas/pantalla_ajustes.dart` | Modify | Distinguish main EQ from favorite-station own EQ controls. | +| `lib/modelos/preset_ecualizador.dart` | Modify | Add equality/copy helpers if tests need deterministic comparisons. | +| `test/servicios/servicio_radio_test.dart` | Create | Retry/host rotation tests. | +| `test/estado/estado_radio_test.dart` | Create | EQ persistence, custom listing, and station EQ policy tests. | +| `test/pantallas/pantalla_inicio_test.dart` | Create | Widget coverage for custom station rendering/favorite action. | + +## Interfaces / Contracts + +Suggested public surface: + +```dart +class ServicioEcualizador { + Future cargar(); + Future guardarPrincipal(PresetEcualizador preset); + Future guardarPorEmisora(String uuid, PresetEcualizador preset); + Future eliminarPorEmisora(String uuid); +} +``` + +`ConfiguracionEcualizador` contains `PresetEcualizador principal` and `Map porEmisora`. + +## Testing Strategy + +| Layer | What to Test | Approach | +|---|---|---| +| Unit | `ServicioRadio` retries and rotates hosts | Fake `http.Client`, zero delay. | +| Unit | EQ load/save and fallback selection | SharedPreferences mock values + fake audio service. | +| Unit | Custom station added appears in combined listing | Inject fake custom storage or initial state. | +| Widget | Home renders custom station and favorite tap persists | Pump `PantallaInicio` with test `EstadoRadio`. | +| Regression | Current station updates for mini player/station EQ | Fake audio handler/service assertions. | + +## Migration / Rollout + +No destructive migration. On first run without EQ keys, use `PresetEcualizador.flat` as main EQ and empty per-station map. Existing export/import keys can be read opportunistically but app-local persistence should use the new service. + +## Open Questions + +- None blocking. The first implementation can place station EQ controls in Settings for the currently playing favorite; a richer per-favorite management screen can be deferred. diff --git a/openspec/changes/startup-retry-custom-stations-eq-persistence/explore.md b/openspec/changes/startup-retry-custom-stations-eq-persistence/explore.md new file mode 100644 index 0000000..9daba28 --- /dev/null +++ b/openspec/changes/startup-retry-custom-stations-eq-persistence/explore.md @@ -0,0 +1,69 @@ +# Exploration: Startup retry, custom stations, and equalizer persistence + +## Current State + +- `lib/main.dart` only initializes `AudioService` and launches `PluriWaveApp`; app data initialization starts later when `EstadoRadio` is created by `ChangeNotifierProvider` in `lib/app.dart`. +- `EstadoRadio` constructor calls `_init()` without awaiting it. `_init()` runs `Future.wait([cargarPopulares(), cargarFavoritos(), _cargarEmisoresCustom()])`. +- `cargarPopulares()` fetches `radio.obtenerPopulares()` and `radio.obtenerTendencias()` once. On any failure it sets `_errorCarga = 'Sin conexion a la API de radio'`; the UI exposes a manual "Reintentar" button but there is no automatic startup retry. +- `ServicioRadio._get()` picks one fallback Radio Browser host, applies a 10s timeout, and clears `_servidorActual` on error so a later call can rotate. It does not retry within the same request. +- Custom stations are stored as JSON from inside `EstadoRadio` (`emisoras_custom.json`) and are only rendered in `_SeccionEmisoras` in `pantalla_ajustes.dart`. `PantallaInicio` lists API stations only, so custom stations never enter the main grid. `TarjetaEmisora` already supports favorites for any `Emisora`. +- Equalizer state is in memory only: `_presetActual` plus `_presetsEmisoraMap` in `EstadoRadio`. Export/import includes station preset maps, but startup never loads EQ state from app storage. +- Per-station EQ is currently unreliable because `EstadoRadio.cambiarPresetEcualizador()` stores by `emisoraActual`, while `PluriWaveAudioHandler.emisoraActual` is declared but never assigned when playback starts. +- Existing tests are only `test/widget_test.dart`, a placeholder asserting `true`. Strict TDD is active, so the implementation needs test seams before behavior changes. + +## Affected Areas + +- `lib/servicios/servicio_radio.dart` - startup/API fetch resilience and host retry behavior. +- `lib/estado/estado_radio.dart` - startup orchestration, custom station listing, EQ load/save, station EQ selection. +- `lib/servicios/servicio_audio.dart` - current station tracking needed for mini player and per-station EQ. +- `lib/pantallas/pantalla_inicio.dart` - main listing must include custom stations. +- `lib/pantallas/pantalla_ajustes.dart` / `lib/widgets/ecualizador_widget.dart` - EQ scope controls and persisted graph display. +- `lib/modelos/preset_ecualizador.dart` - equality/copy helpers may be needed for persistence tests. +- `test/` - new unit/widget tests for startup retry, custom station listing/favorites, and EQ persistence. + +## Approaches + +1. **Retry in `EstadoRadio.cargarPopulares()`** + - Pros: small change; directly targets startup; easy to preserve `ServicioRadio`. + - Cons: search calls still lack retry; duplicated retry policy if other API calls need it. + - Effort: Low. + +2. **Retry inside `ServicioRadio._get()` with host rotation** + - Pros: centralizes network resilience; startup and search benefit; keeps `EstadoRadio` simpler. + - Cons: needs injectable HTTP client/delay for tests; must avoid long blocking startup. + - Effort: Medium. + +3. **Merge custom stations into main grid via `EstadoRadio` getter** + - Pros: minimal UI disruption; `TarjetaEmisora` already handles favorite toggles. + - Cons: custom and API stations share one grid; no separate "My stations" visual grouping. + - Effort: Low. + +4. **Separate "Mis emisoras" section on `PantallaInicio`** + - Pros: clearer UX; no confusion with API popularity ranking. + - Cons: more UI work and widget tests; still must define empty/genre-filter behavior. + - Effort: Medium. + +5. **Persist EQ in SharedPreferences through a dedicated service** + - Pros: dependency already exists; JSON values fit `PresetEcualizador`; avoids SQLite migration. + - Cons: less relational than SQLite; needs careful key/version handling. + - Effort: Medium. + +6. **Persist EQ in SQLite favorites table** + - Pros: station-specific EQ close to favorite rows. + - Cons: requires DB migration and does not naturally store global/main EQ. + - Effort: High. + +## Recommendation + +Use service-level radio retry with host rotation, expose a combined main-station getter for custom stations, and introduce a small `ServicioEcualizador` backed by SharedPreferences. Represent station EQ as "entry exists = own EQ; no entry = use main EQ". Also fix current-station tracking in the audio layer before wiring station EQ; otherwise the UI and per-station persistence sit on arena sand, not concrete. + +## Risks + +- Retrying both popular and trending in parallel can lengthen startup; cap attempts and use short backoff. +- SharedPreferences persistence must be loaded before the first playback applies EQ. +- Android equalizer availability is runtime-dependent; persisted EQ must still load even when native EQ is not available yet. +- Adding test seams can touch constructors; keep backward-compatible defaults. + +## Ready for Proposal + +Yes. The code paths are clear enough to plan without production changes. diff --git a/openspec/changes/startup-retry-custom-stations-eq-persistence/proposal.md b/openspec/changes/startup-retry-custom-stations-eq-persistence/proposal.md new file mode 100644 index 0000000..7b02971 --- /dev/null +++ b/openspec/changes/startup-retry-custom-stations-eq-persistence/proposal.md @@ -0,0 +1,72 @@ +# Proposal: Startup retry, custom stations, and equalizer persistence + +## Intent + +Make startup resilient to transient Radio Browser/API failures, make user-added stations first-class in the main listing/favorites flow, and persist equalizer choices so the app restores the configured graph and respects station-specific EQ. + +## Scope + +### In Scope +- Add bounded retry with host rotation for initial radio API fetches. +- Show custom stations from Settings in the main station listing. +- Keep custom stations favorite-capable through existing favorite persistence. +- Persist main EQ and per-favorite station EQ. +- Apply station EQ when present; otherwise apply main EQ. +- Add strict-TDD tests before implementation. + +### Out of Scope +- `flutter build` or release packaging. +- Replacing Provider/ChangeNotifier. +- Full redesign of radio browsing/search UX. +- Cloud sync of EQ or station data. + +## Capabilities + +### New Capabilities +- `startup-radio-loading`: resilient startup station loading with bounded retry. +- `custom-stations-listing`: custom stations appear in the main listing and can be favorites. +- `equalizer-persistence`: main EQ persists and loads on startup. +- `station-equalizer`: favorite stations may own EQ or use main EQ. + +### Modified Capabilities +- None; no existing OpenSpec source specs exist yet. + +## Approach + +Centralize API retry in `ServicioRadio`, add test-friendly dependency injection, expose a combined main listing in `EstadoRadio`, and introduce `ServicioEcualizador` using SharedPreferences JSON. Use null/no entry for station EQ fallback to main EQ. + +## Affected Areas + +| Area | Impact | Description | +|---|---|---| +| `lib/servicios/servicio_radio.dart` | Modified | Retry/host rotation and injectable HTTP client/delay. | +| `lib/estado/estado_radio.dart` | Modified | Init flow, main listing getter, EQ load/save/apply policy. | +| `lib/servicios/servicio_audio.dart` | Modified | Track current station correctly for UI and EQ. | +| `lib/servicios/servicio_ecualizador.dart` | New | Persist main and station EQ settings. | +| `lib/pantallas/pantalla_inicio.dart` | Modified | Render custom stations in the main list. | +| `lib/pantallas/pantalla_ajustes.dart` | Modified | EQ scope controls/load state. | +| `test/` | Modified | Unit/widget tests for all spec scenarios. | + +## Risks + +| Risk | Likelihood | Mitigation | +|---|---:|---| +| Startup delay from retries | Med | Cap attempts, short backoff, preserve manual retry. | +| Native EQ unavailable before playback | Med | Persist Provider state independently from Android EQ. | +| Constructor changes break widgets | Low | Keep default constructors backward compatible. | + +## Rollback Plan + +Revert the change folder and implementation commits. Since persistence uses new SharedPreferences keys, rollback can ignore them safely. + +## Dependencies + +- Existing `shared_preferences`, `http`, `provider`, `flutter_test`. + +## Success Criteria + +- [ ] Transient startup API failure retries and succeeds without showing final error. +- [ ] Exhausted startup attempts leave the app usable with manual retry. +- [ ] Custom stations appear in `PantallaInicio` and can be favorited. +- [ ] Main EQ restores after app restart. +- [ ] Favorite station own EQ overrides main EQ only when enabled. diff --git a/openspec/changes/startup-retry-custom-stations-eq-persistence/spec.md b/openspec/changes/startup-retry-custom-stations-eq-persistence/spec.md new file mode 100644 index 0000000..a54505d --- /dev/null +++ b/openspec/changes/startup-retry-custom-stations-eq-persistence/spec.md @@ -0,0 +1,83 @@ +# Spec: Startup retry, custom stations, and equalizer persistence + +## Requirement: Startup radio loading resilience + +The system MUST retry initial Radio Browser station loading with bounded attempts and host rotation before exposing a startup connection error. + +### Scenario: transient startup failure recovers +- GIVEN the first radio API request fails due to a transient connection error +- WHEN the app initializes `EstadoRadio` +- THEN the system MUST retry using an eligible host +- AND popular/tending stations SHALL be populated when a later attempt succeeds +- AND no final startup error SHALL remain visible + +### Scenario: startup failures are exhausted +- GIVEN all configured retry attempts fail +- WHEN initial station loading completes +- THEN the system MUST stop retrying automatically +- AND it MUST expose a user-visible connection error +- AND the manual retry action MUST still call station loading again + +## Requirement: Custom stations in the main listing + +The system MUST include stations added in Settings in the main station listing and MUST allow them to be marked/unmarked as favorites through the same favorite flow as API stations. + +### Scenario: added custom station appears on home +- GIVEN a user saves a valid custom station in Settings +- WHEN the main listing is rendered without a genre filter +- THEN the custom station MUST be visible in the listing +- AND selecting it MUST start playback through the normal `EstadoRadio.reproducir` path + +### Scenario: custom station can be favorited +- GIVEN a custom station is visible in the main listing +- WHEN the user taps its favorite action +- THEN the station MUST be persisted in favorites +- AND the Favorites screen SHALL show it after favorite state reloads + +## Requirement: Main equalizer persistence + +The system MUST persist the main equalizer preset/graph and MUST load it during state initialization before the first playback-specific EQ decision. + +### Scenario: main EQ restores after restart +- GIVEN the user configures the main EQ to a non-flat graph +- WHEN the app state is recreated +- THEN `EstadoRadio.presetEcualizador` MUST expose the saved graph +- AND the native audio equalizer SHALL receive it when the EQ becomes available + +### Scenario: EQ state persists even when native EQ is unavailable +- GIVEN the platform has no available native equalizer at startup +- WHEN persisted EQ settings are loaded +- THEN Provider state MUST still expose the saved EQ graph +- AND no persistence data SHALL be discarded + +## Requirement: Favorite station equalizer mode + +The system MUST let each favorite station either use the main equalizer or use its own saved equalizer. If own EQ exists for a station, playback MUST respect it. + +### Scenario: favorite station own EQ is applied +- GIVEN a favorite station has its own saved EQ graph +- WHEN the user plays that station +- THEN the system MUST apply the station EQ graph +- AND it MUST expose that graph as the current EQ in state + +### Scenario: favorite station falls back to main EQ +- GIVEN a favorite station has no own EQ enabled +- AND the main EQ is configured +- WHEN the user plays that station +- THEN the system MUST apply the main EQ graph + +### Scenario: disabling own EQ restores main behavior +- GIVEN a favorite station has own EQ enabled +- WHEN the user switches that station to use main EQ +- THEN the station-specific EQ entry MUST be removed or ignored +- AND future playback of that station MUST use the current main EQ + +## Requirement: Test-first implementation + +The implementation MUST add failing tests for each scenario before production behavior is changed. + +### Scenario: strict TDD guardrail +- GIVEN this change is implemented +- WHEN an implementation task begins +- THEN the corresponding test MUST be written first +- AND verification MUST use `flutter test`, never `flutter build` diff --git a/openspec/changes/startup-retry-custom-stations-eq-persistence/state.yaml b/openspec/changes/startup-retry-custom-stations-eq-persistence/state.yaml new file mode 100644 index 0000000..a526efa --- /dev/null +++ b/openspec/changes/startup-retry-custom-stations-eq-persistence/state.yaml @@ -0,0 +1,48 @@ +schema: spec-driven +change: startup-retry-custom-stations-eq-persistence +project: pluriwave +artifact_store: hybrid +execution_mode: auto +strict_tdd: true +test_command: flutter test +build_command: null +current_phase: apply-partial +next_recommended: sdd-verify +phases: + explore: + status: completed + file: openspec/changes/startup-retry-custom-stations-eq-persistence/explore.md + engram_topic_key: sdd/startup-retry-custom-stations-eq-persistence/explore + proposal: + status: completed + file: openspec/changes/startup-retry-custom-stations-eq-persistence/proposal.md + engram_topic_key: sdd/startup-retry-custom-stations-eq-persistence/proposal + spec: + status: completed + file: openspec/changes/startup-retry-custom-stations-eq-persistence/spec.md + engram_topic_key: sdd/startup-retry-custom-stations-eq-persistence/spec + design: + status: completed + file: openspec/changes/startup-retry-custom-stations-eq-persistence/design.md + engram_topic_key: sdd/startup-retry-custom-stations-eq-persistence/design + tasks: + status: completed + file: openspec/changes/startup-retry-custom-stations-eq-persistence/tasks.md + engram_topic_key: sdd/startup-retry-custom-stations-eq-persistence/tasks + apply: + status: partial + file: openspec/changes/startup-retry-custom-stations-eq-persistence/apply-progress.md + engram_topic_key: sdd/startup-retry-custom-stations-eq-persistence/apply-progress + verify: + status: pending + archive: + status: pending +constraints: + - Do not implement production code during planning. + - Do not run flutter build. + - Apply strict TDD: write failing tests before production changes. + - Preserve Spanish folder/naming style and Provider/ChangeNotifier patterns. +risks: + - Retry policy must not block startup for too long. + - Native equalizer availability is platform/runtime dependent. + - Current station tracking must be fixed for station-specific EQ to be reliable. diff --git a/openspec/changes/startup-retry-custom-stations-eq-persistence/tasks.md b/openspec/changes/startup-retry-custom-stations-eq-persistence/tasks.md new file mode 100644 index 0000000..64a45cd --- /dev/null +++ b/openspec/changes/startup-retry-custom-stations-eq-persistence/tasks.md @@ -0,0 +1,39 @@ +# Tasks: Startup retry, custom stations, and equalizer persistence + +## Phase 1: Test seams and RED tests + +- [x] 1.1 RED: Add `test/servicios/servicio_radio_test.dart` proving a transient HTTP failure retries with another host and succeeds. +- [x] 1.2 RED: Add `test/servicios/servicio_radio_test.dart` proving exhausted retries surface an error after the configured attempt cap. +- [x] 1.3 RED: Add `test/estado/estado_radio_test.dart` proving a saved custom station is included in the main listing getter. +- [x] 1.4 RED: Add `test/estado/estado_radio_test.dart` proving main EQ loads from persistence before playback EQ selection. +- [x] 1.5 RED: Add station EQ tests proving own EQ overrides main and disabled own EQ falls back to main. +- [x] 1.6 RED: Add/update a widget test proving `PantallaInicio` renders custom stations and favorite tap calls the favorite flow. + +## Phase 2: Foundation / services + +- [x] 2.1 GREEN: Modify `ServicioRadio` to accept injectable `http.Client`, host list, max attempts, and retry delay while preserving default constructor behavior. +- [x] 2.2 GREEN: Implement bounded retry/host rotation in `ServicioRadio._get()` without adding unbounded startup waits. +- [x] 2.3 GREEN: Create `lib/servicios/servicio_ecualizador.dart` with SharedPreferences JSON load/save for main EQ and per-station EQ map. +- [x] 2.4 GREEN: Add equality/copy helpers to `PresetEcualizador` only if required by tests. + +## Phase 3: State integration + +- [x] 3.1 GREEN: Update `EstadoRadio` constructor for optional service injection and load EQ config during `_init()`. +- [x] 3.2 GREEN: Add a combined main listing getter (`emisorasInicio` or equivalent) that includes custom stations plus API stations without duplicates. +- [x] 3.3 GREEN: Persist main EQ changes when no station-own EQ scope is active. +- [x] 3.4 GREEN: Add methods to enable/update/disable EQ per favorite station and persist the map. +- [x] 3.5 GREEN: Change playback EQ selection to apply station EQ when present, otherwise main EQ. + +## Phase 4: UI wiring + +- [x] 4.1 GREEN: Update `PantallaInicio` to render the combined main listing when no genre filter is selected. +- [x] 4.2 GREEN: Reuse `TarjetaEmisora` favorite controls for custom stations; do not introduce a parallel favorite path. +- [x] 4.3 GREEN: Update `PantallaAjustes` EQ controls to show/edit main EQ and, when the current station is a favorite, allow own-EQ vs main-EQ mode. +- [x] 4.4 GREEN: Fix `ServicioAudio`/handler so `emisoraActual` is set on successful playback and cleared on stop/error as tests require. + +## Phase 5: REFACTOR and verification + +- [x] 5.1 REFACTOR: Remove duplicated JSON/persistence logic from `EstadoRadio` where the new service owns it. +- [x] 5.2 REFACTOR: Keep Spanish naming and Provider/ChangeNotifier style consistent with existing folders. +- [ ] 5.3 VERIFY: Run `flutter test` only; never run `flutter build`. +- [ ] 5.4 VERIFY: Compare passing tests against every Given/When/Then scenario in `spec.md`. diff --git a/openspec/changes/startup-retry-custom-stations-eq-persistence/verify-report.md b/openspec/changes/startup-retry-custom-stations-eq-persistence/verify-report.md new file mode 100644 index 0000000..1315276 --- /dev/null +++ b/openspec/changes/startup-retry-custom-stations-eq-persistence/verify-report.md @@ -0,0 +1,222 @@ +# Verification Report: startup-retry-custom-stations-eq-persistence + +**Status**: blocked +**Mode**: Strict TDD +**Date**: 2026-04-27 +**Verifier**: sdd-verify worker 2 +**Round**: re-verify after targeted apply-fix + +Runtime verification remains blocked because `flutter` is not available in PATH in this shell. Static review confirms the targeted fixes from the prior verification round are present. This change is still **not archiveable** because Strict TDD requires passing `flutter test` evidence before scenarios can be marked compliant and before tasks 5.3/5.4 can be completed. + +--- + +## Completeness + +| Metric | Value | +|---|---:| +| Tasks total | 23 | +| Tasks complete | 21 | +| Tasks incomplete | 2 | + +Incomplete by design until runtime evidence exists: + +- `[ ] 5.3 VERIFY: Run flutter test only; never run flutter build.` +- `[ ] 5.4 VERIFY: Compare passing tests against every Given/When/Then scenario in spec.md.` + +Tasks 5.3 and 5.4 **must remain incomplete**. `flutter test` was attempted and could not run because Flutter is missing from PATH. + +--- + +## Validation Commands Attempted + +| Command | Exit | Result | +|---|---:|---| +| `git status --short` | 0 | Confirmed active uncommitted work exists; verification did not revert or edit production code. | +| `Get-Command flutter -ErrorAction SilentlyContinue` | 127 | `FLUTTER_NOT_FOUND: Get-Command flutter returned no executable on PATH.` | +| `flutter test` | 1 | `CommandNotFoundException`: `flutter` is not recognized as a cmdlet/program in this PowerShell session. | +| `flutter build` | N/A | Not run; prohibited by project and mission constraints. | + +Coverage, linter, and type-check commands were not run because the configured tools are Flutter-backed (`flutter test --coverage`, `flutter analyze`) and Flutter is unavailable. + +--- + +## TDD Compliance + +| Check | Result | Details | +|---|---|---| +| TDD Evidence reported | PASSED STATIC | `apply-progress.md` now contains `## TDD Cycle Evidence`. | +| Test files exist | PASSED STATIC | Verified `test/servicios/servicio_radio_test.dart`, `test/estado/estado_radio_test.dart`, and `test/pantallas/pantalla_inicio_test.dart`. | +| RED confirmed | PARTIAL / BLOCKED | Test intent is documented and test files exist, but RED-first ordering cannot be independently proven without historical run output. | +| GREEN confirmed | BLOCKED | `flutter test` cannot execute in this environment. | +| Triangulation adequate | PARTIAL / BLOCKED | Targeted missing scenarios now have tests; runtime proof is blocked. | +| Safety net for modified files | BLOCKED | Apply-progress records the same Flutter availability blocker. | + +**TDD compliance verdict**: blocked. The prior missing-evidence-table finding is fixed, but Strict TDD cannot pass until `flutter test` runs successfully. + +--- + +## Test Layer Distribution + +| Layer | Tests | Files | Tool | +|---|---:|---:|---| +| Unit/service | 2 | 1 | `flutter_test` + `http/testing` | +| Unit/state | 6 | 1 | `flutter_test` | +| Widget/component | 3 | 1 | `flutter_test` | +| E2E | 0 | 0 | Not configured | +| **Total** | **11** | **3** | | + +Related helper file: + +- `test/helpers/fakes.dart` + +--- + +## Scenario Coverage Matrix + +Runtime result is `BLOCKED` for every scenario because no test execution was possible. Under Strict TDD, no scenario can be marked compliant until a covering test has passed. + +| Requirement | Scenario | Static implementation evidence | Test evidence found | Result | +|---|---|---|---|---| +| Startup radio loading resilience | transient startup failure recovers | `ServicioRadio._get()` retries with host rotation; `EstadoRadio._init()` calls station loading after EQ load. | `test/servicios/servicio_radio_test.dart` covers retry/host rotation success. No direct `EstadoRadio` startup integration test for final visible error clearing, but design assigns retry ownership to `ServicioRadio`. | PARTIAL STATIC / BLOCKED | +| Startup radio loading resilience | startup failures are exhausted | `ServicioRadio._get()` caps attempts; `EstadoRadio.cargarPopulares()` exposes `Sin conexión a la API de radio`; `PantallaInicio` has `Reintentar`. | Service cap/error test exists. State and widget tests now cover startup failure plus manual retry recovery. | COVERED STATICALLY / BLOCKED | +| Custom stations in main listing | added custom station appears on home | `EstadoRadio.emisorasInicio` combines custom + popular; `PantallaInicio` uses it without genre filter; tap calls `EstadoRadio.reproducir`. | State getter test exists. Widget test now renders `Custom Uno` and taps it, asserting fake audio playback and click registration. | COVERED STATICALLY / BLOCKED | +| Custom stations in main listing | custom station can be favorited | `TarjetaEmisora` uses the same `EstadoRadio.toggleFavorito` flow for all station cards; `toggleFavorito` reloads favorites. | Widget test taps favorite on the custom station. Widget test now opens `PantallaFavoritos` and verifies the custom station appears after reload. | COVERED STATICALLY / BLOCKED | +| Main equalizer persistence | main EQ restores after restart | `ServicioEcualizador.cargar()` loads persisted main EQ; `EstadoRadio._cargarEcualizadorPersistido()` sets provider state before station loading; playback applies selected EQ. | State test covers persisted main EQ before playback EQ selection. | COVERED STATICALLY / BLOCKED | +| Main equalizer persistence | EQ state persists even when native EQ is unavailable | `EstadoRadio` loads persisted EQ independent of `audio.ecualizadorDisponible`; production `ServicioAudio.aplicarPreset()` stores `_presetActual` before returning when native EQ is unavailable. | State test now uses `FakeServicioAudio(ecualizadorActivo: false)` and verifies main + per-station persisted EQ remain exposed. | COVERED STATICALLY / BLOCKED | +| Favorite station equalizer mode | favorite station own EQ is applied | `_presetParaEmisora()` prefers `_presetsEmisoraMap[uuid]`; `reproducir()` applies selected preset and updates state. | State test covers own EQ overriding main. | COVERED STATICALLY / BLOCKED | +| Favorite station equalizer mode | favorite station falls back to main EQ | Map absence falls back to `_presetPrincipal`. | State test now covers a favorite without own EQ using main EQ from first play. | COVERED STATICALLY / BLOCKED | +| Favorite station equalizer mode | disabling own EQ restores main behavior | `deshabilitarPresetEcualizadorPorEmisora()` removes persisted entry and applies main when current station matches. | State test covers disabling own EQ and replaying with main fallback. | COVERED STATICALLY / BLOCKED | +| Test-first implementation | strict TDD guardrail | `openspec/config.yaml` and `state.yaml` have Strict TDD active; `apply-progress.md` now records TDD cycle evidence and blocked Flutter commands. | TDD table exists, but `flutter test` cannot run, so GREEN evidence is unavailable. | PARTIAL STATIC / BLOCKED | + +**Runtime compliance summary**: 0/10 scenarios compliant because no covering test has passed in this environment. +**Static coverage summary**: 8/10 scenarios covered statically, 2/10 partial statically. + +--- + +## Prior Findings Re-check + +| Prior finding | Status | Evidence | +|---|---|---| +| `mediaItem.add(null)` shadowing in `playMediaItem(MediaItem mediaItem)` | FIXED | `lib/servicios/servicio_audio.dart:239` now uses `this.mediaItem.add(null)`. Other `mediaItem.add(null)` calls are outside the shadowing scope. | +| `apply-progress.md` missing Strict TDD Cycle Evidence | FIXED | `openspec/changes/startup-retry-custom-stations-eq-persistence/apply-progress.md` now contains `## TDD Cycle Evidence`. | +| Missing native-EQ-unavailable test | FIXED STATICALLY | `test/estado/estado_radio_test.dart` includes `mantiene EQ persistido aunque el ecualizador nativo no esté disponible`. | +| Missing manual retry test | FIXED STATICALLY | `test/pantallas/pantalla_inicio_test.dart` includes `PantallaInicio permite reintentar manualmente tras fallo inicial agotado`; state test also checks manual `cargarPopulares()` recovery. | +| Missing custom station playback tap test | FIXED STATICALLY | `test/pantallas/pantalla_inicio_test.dart` taps `Custom Uno` and asserts playback via fake audio. | +| Missing favorites screen reload test | FIXED STATICALLY | `test/pantallas/pantalla_inicio_test.dart` opens `PantallaFavoritos` after favoriting and expects `Custom Uno`. | +| Missing favorite main-EQ fallback test | FIXED STATICALLY | `test/estado/estado_radio_test.dart` includes `favorita sin EQ propio usa EQ principal desde el primer play`. | +| `PantallaAjustes` async per-band loop | FIXED | `lib/pantallas/pantalla_ajustes.dart:94-98` calls `estado.cambiarPresetEcualizador(p)` once from `EcualizadorWidget.onCambio`; no per-band async loop remains in the widget. | + +--- + +## Correctness: Static Structural Evidence + +| Requirement | Status | Notes | +|---|---|---| +| Startup radio loading resilience | PARTIAL STATIC | Retry/host rotation and exhausted retry behavior exist. Manual retry coverage was added. Automatic transient startup recovery is proven primarily at the service layer, not with a direct `EstadoRadio` startup integration test. | +| Custom stations in the main listing | IMPLEMENTED STATICALLY | Combined listing, normal playback path, same favorite flow, and favorites screen reload are represented in code/tests. | +| Main equalizer persistence | IMPLEMENTED STATICALLY | EQ persistence service, provider load, native-unavailable state retention, and playback application are represented in code/tests. | +| Favorite station equalizer mode | IMPLEMENTED STATICALLY | Own EQ, main fallback, and disabling own EQ are represented in state code/tests. | +| Test-first implementation | BLOCKED | Strict TDD metadata and TDD evidence table exist; runtime GREEN evidence is blocked by missing Flutter. | + +--- + +## Coherence: Design Decisions + +| Decision | Followed? | Notes | +|---|---|---| +| API retry owner in `ServicioRadio._get()` | Yes | Bounded retry/host rotation remains centralized in `ServicioRadio._get()`. | +| Custom stations home UX via combined listing | Yes | `EstadoRadio.emisorasInicio` combines custom + popular; `PantallaInicio` uses it when no genre filter is selected. | +| EQ storage in `ServicioEcualizador` with SharedPreferences JSON | Yes | `ServicioEcualizador` stores main and per-station EQ JSON. | +| Station EQ fallback by `Map` absence | Yes | `_presetParaEmisora()` returns station preset or main preset. | +| Current station source in `ServicioAudio`/handler | Yes static | `ServicioAudio` sets/clears `emisoraActual`; the previously shadowed `playMediaItem` exception path now uses `this.mediaItem.add(null)`. | + +--- + +## Assertion Quality Audit + +Reviewed related tests: + +- `test/servicios/servicio_radio_test.dart` +- `test/estado/estado_radio_test.dart` +- `test/pantallas/pantalla_inicio_test.dart` + +**Assertion quality**: PASSED STATIC REVIEW. No tautological assertions, ghost loops, or smoke-only widget tests were found in the reviewed change tests. Assertions check concrete behavior: host sequence, call counts, visible text, persisted favorite state, selected EQ preset, and playback calls. + +--- + +## Issues Found + +### CRITICAL + +None found in static re-review after the targeted fixes. + +### WARNING + +1. Runtime verification is blocked because Flutter is unavailable. This is not a production-code defect, but it blocks Strict TDD completion and archive. +2. The transient startup recovery scenario is still proven most directly at `ServicioRadio` level rather than by a direct `EstadoRadio.inicializar()` integration test that starts with a transient service failure and ends with no visible startup error. Given the design decision that retry ownership lives in `ServicioRadio`, this is a coverage nuance, not a new blocker beyond runtime execution. + +### SUGGESTION + +1. When Flutter is available, run `flutter test` first; only after it passes should tasks 5.3/5.4 be marked complete. +2. Consider adding one direct `EstadoRadio.inicializar()` transient-recovery test later if the team wants tighter end-to-end coverage for the first startup scenario. + +--- + +## Changed File Coverage + +Coverage analysis skipped: Flutter is unavailable, so `flutter test --coverage` cannot run. + +--- + +## Quality Metrics + +**Linter**: Not run (`flutter analyze` requires Flutter in PATH). +**Type checker**: Not run (`flutter analyze` requires Flutter in PATH). +**Build**: Not run; build commands are prohibited by project and mission constraints. + +--- + +## Tasks 5.3 / 5.4 Status + +| Task | Status | Reason | +|---|---|---| +| 5.3 VERIFY: Run `flutter test` only; never run `flutter build`. | INCOMPLETE | `flutter test` was attempted but failed before execution because the `flutter` command is unavailable. | +| 5.4 VERIFY: Compare passing tests against every Given/When/Then scenario. | INCOMPLETE | No test passed at runtime in this environment, so scenario compliance cannot be accepted under Strict TDD. | + +Do not mark either task complete until `flutter test` actually passes. + +--- + +## Verdict + +**BLOCKED, not archiveable.** + +Static targeted fixes are present and no new static blockers were found, but Strict TDD runtime verification cannot pass without a successful `flutter test` run. + +--- + +## Next Recommended + +1. Install/expose Flutter on PATH for the verification environment. +2. Run `flutter test` only; do **not** run `flutter build`. +3. If tests pass, update tasks 5.3/5.4 and rerun verify to produce a passing scenario compliance matrix. + +--- + +## Risks + +- Until `flutter test` runs, compile errors or failing tests may still exist undetected. +- Archive would be premature because Strict TDD requires passing behavioral evidence, not just static review. + +--- + +## Artifacts Updated + +- `openspec/changes/startup-retry-custom-stations-eq-persistence/verify-report.md` +- Engram topic `sdd/startup-retry-custom-stations-eq-persistence/verify-report` + +--- + +## Skill Resolution + +injected diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..b52c35d --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,64 @@ +schema: spec-driven + +context: | + Tech stack: Flutter/Dart app for Android+iOS. + Architecture: Provider/ChangeNotifier with Spanish domain folders: estado, modelos, pantallas, servicios, widgets. + Testing: flutter_test via `flutter test`; Strict TDD enabled. + Style: flutter_lints via analysis_options.yaml; use flutter analyze and dart format. + Constraint: never run flutter build after changes. + +strict_tdd: true + +testing: + strict_tdd: true + detected: 2026-04-27 + test_runner: + framework: flutter_test + command: flutter test + layers: + unit: + available: true + tool: flutter_test + integration: + available: false + tool: null + e2e: + available: false + tool: null + coverage: + available: true + command: flutter test --coverage + quality_tools: + linter: + available: true + command: flutter analyze + type_checker: + available: true + command: flutter analyze + formatter: + available: true + command: dart format . + +rules: + proposal: + - Include rollback plan for risky changes + - Identify affected modules/packages + specs: + - Use Given/When/Then format for scenarios + - Use RFC 2119 keywords + design: + - Include architecture decisions with rationale + tasks: + - Group tasks by phase + - Use hierarchical numbering + - Keep tasks small enough to complete in one session + apply: + - Follow existing code patterns and conventions + - Strict TDD Mode is active + - Never run flutter build + verify: + - Run tests if possible + - Compare implementation against every spec scenario + - Never run flutter build + archive: + - Persist final state to Engram and openspec diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart new file mode 100644 index 0000000..60d0bf7 --- /dev/null +++ b/test/estado/estado_radio_test.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; + +import '../helpers/fakes.dart'; + +void main() { + group('EstadoRadio integración de custom + EQ persistente', () { + test('incluye emisoras custom en el listado principal de inicio', () async { + final archivo = await _crearArchivoCustom([ + emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'), + ]); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio( + populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], + ), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + + expect( + estado.emisorasInicio.map((e) => e.uuid).toList(), + equals(['custom-1', 'api-1']), + ); + }); + + test('carga EQ principal persistido antes de decidir EQ de reproducción', + () async { + final audio = FakeServicioAudio(); + final principal = PresetEcualizador.rock; + final emisora = emisoraDemo(uuid: 'api-1', nombre: 'API Uno'); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(populares: [emisora]), + servicioEcualizador: FakeServicioEcualizador(principal: principal), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + await estado.reproducir(emisora); + + expect(estado.presetEcualizador, principal); + expect(audio.presetsAplicados.first, principal); + expect(audio.presetsAplicados.last, principal); + }); + + test('mantiene EQ persistido aunque el ecualizador nativo no esté disponible', + () async { + final principal = PresetEcualizador.jazz; + final porEmisora = {'fav-1': PresetEcualizador.rock}; + final estado = EstadoRadio( + audio: FakeServicioAudio(ecualizadorActivo: false), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador( + principal: principal, + porEmisora: porEmisora, + ), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + + expect(estado.ecualizadorDisponible, isFalse); + expect(estado.presetEcualizador, principal); + expect(estado.presetPrincipalEcualizador, principal); + expect( + estado.presetEcualizadorPorEmisora('fav-1'), + PresetEcualizador.rock, + ); + }); + + test( + 'inicializar deja error tras fallo y cargarPopulares manual recupera estaciones', + () async { + final radio = FakeServicioRadio( + erroresPopularesPorLlamada: [Exception('sin red')], + popularesPorLlamada: [ + const [], + [emisoraDemo(uuid: 'api-ok', nombre: 'API Recuperada')], + ], + tendenciasPorLlamada: [ + const [], + [emisoraDemo(uuid: 'trend-ok', nombre: 'Trend Recuperada')], + ], + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: radio, + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + expect(estado.error, 'Sin conexión a la API de radio'); + + await estado.cargarPopulares(); + + expect(estado.error, isNull); + expect(estado.populares.map((e) => e.uuid), contains('api-ok')); + expect(estado.tendencias.map((e) => e.uuid), contains('trend-ok')); + expect(radio.obtenerPopularesCalls, 2); + }); + + test('EQ propio por emisora pisa al principal y puede volver a fallback', + () async { + final audio = FakeServicioAudio(); + final favoritos = FakeServicioFavoritos(); + final emisora = emisoraDemo(uuid: 'fav-1', nombre: 'Favorita'); + final principal = PresetEcualizador.pop; + final propio = PresetEcualizador.jazz; + await favoritos.agregar(emisora); + + final estado = EstadoRadio( + audio: audio, + favoritos: favoritos, + radio: FakeServicioRadio(populares: [emisora]), + servicioEcualizador: FakeServicioEcualizador(principal: principal), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + await estado.cargarFavoritos(); + await estado.guardarPresetEcualizadorPorEmisora(emisora.uuid, propio); + + await estado.reproducir(emisora); + expect(estado.presetEcualizador, propio); + expect(audio.presetsAplicados.last, propio); + + await estado.deshabilitarPresetEcualizadorPorEmisora(emisora.uuid); + await estado.reproducir(emisora); + expect(estado.presetEcualizador, principal); + expect(audio.presetsAplicados.last, principal); + }); + + test('favorita sin EQ propio usa EQ principal desde el primer play', () async { + final audio = FakeServicioAudio(); + final favoritos = FakeServicioFavoritos(); + final emisora = emisoraDemo(uuid: 'fav-main', nombre: 'Favorita Main'); + final principal = PresetEcualizador.classical; + await favoritos.agregar(emisora); + + final estado = EstadoRadio( + audio: audio, + favoritos: favoritos, + radio: FakeServicioRadio(populares: [emisora]), + servicioEcualizador: FakeServicioEcualizador(principal: principal), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + await estado.cargarFavoritos(); + await estado.reproducir(emisora); + + expect(estado.tienePresetEcualizadorPorEmisora(emisora.uuid), isFalse); + expect(estado.presetEcualizador, principal); + expect(audio.presetsAplicados.last, principal); + }); + }); +} + +Future _archivoCustomVacio() async => _crearArchivoCustom(const []); + +Future _crearArchivoCustom(List emisoras) async { + final dir = await Directory.systemTemp.createTemp('pluriwave-test-'); + final archivo = File('${dir.path}/emisoras_custom.json'); + await archivo.writeAsString(jsonEncode(emisoras.map((e) => e.toMap()).toList())); + return archivo; +} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart new file mode 100644 index 0000000..97542f9 --- /dev/null +++ b/test/helpers/fakes.dart @@ -0,0 +1,213 @@ +import 'dart:async'; + +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:pluriwave/servicios/servicio_ecualizador.dart'; +import 'package:pluriwave/servicios/servicio_favoritos.dart'; +import 'package:pluriwave/servicios/servicio_radio.dart'; + +class FakeServicioAudio extends ServicioAudio { + FakeServicioAudio({this.ecualizadorActivo = true}) { + _estadoController.add(EstadoReproduccion.detenido); + } + + final bool ecualizadorActivo; + final _estadoController = StreamController.broadcast(); + final List presetsAplicados = []; + final List emisorasReproducidas = []; + Emisora? _emisoraActual; + + @override + Emisora? get emisoraActual => _emisoraActual; + + @override + bool get ecualizadorDisponible => ecualizadorActivo; + + @override + Stream get estadoStream => _estadoController.stream; + + @override + Future reproducir(Emisora emisora) async { + _emisoraActual = emisora; + emisorasReproducidas.add(emisora); + _estadoController.add(EstadoReproduccion.reproduciendo); + } + + @override + Future detener() async { + _emisoraActual = null; + _estadoController.add(EstadoReproduccion.detenido); + } + + @override + Future aplicarPreset(PresetEcualizador preset) async { + presetsAplicados.add(preset); + } + + @override + Future setBanda(int index, double db) async {} + + @override + Future dispose() async { + await _estadoController.close(); + } +} + +class FakeServicioFavoritos extends ServicioFavoritos { + final Map _favoritos = {}; + int toggleCalls = 0; + + @override + Future> obtenerTodos() async => _favoritos.values.toList(); + + @override + Future agregar(Emisora emisora) async { + _favoritos[emisora.uuid] = emisora; + } + + @override + Future eliminar(String uuid) async { + _favoritos.remove(uuid); + } + + @override + Future esFavorito(String uuid) async => _favoritos.containsKey(uuid); + + @override + Future toggleFavorito(Emisora emisora) async { + toggleCalls += 1; + if (_favoritos.containsKey(emisora.uuid)) { + _favoritos.remove(emisora.uuid); + return false; + } + _favoritos[emisora.uuid] = emisora; + return true; + } +} + +class FakeServicioRadio extends ServicioRadio { + FakeServicioRadio({ + List? populares, + List? tendencias, + List? busqueda, + List>? popularesPorLlamada, + List>? tendenciasPorLlamada, + List? erroresPopularesPorLlamada, + List? erroresTendenciasPorLlamada, + }) : _populares = populares ?? [], + _tendencias = tendencias ?? [], + _busqueda = busqueda ?? [], + _popularesPorLlamada = popularesPorLlamada ?? const [], + _tendenciasPorLlamada = tendenciasPorLlamada ?? const [], + _erroresPopularesPorLlamada = erroresPopularesPorLlamada ?? const [], + _erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const []; + + final List _populares; + final List _tendencias; + final List _busqueda; + final List> _popularesPorLlamada; + final List> _tendenciasPorLlamada; + final List _erroresPopularesPorLlamada; + final List _erroresTendenciasPorLlamada; + + int obtenerPopularesCalls = 0; + int obtenerTendenciasCalls = 0; + int registrarClickCalls = 0; + String? ultimoUuidClick; + + Exception _normalizarError(Object error) => + error is Exception ? error : Exception(error.toString()); + + @override + Future> obtenerPopulares({int limit = 30}) 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, + }) async { + return _busqueda.take(limit).toList(); + } + + @override + Future registrarClick(String uuid) async { + registrarClickCalls += 1; + ultimoUuidClick = uuid; + } +} + +class FakeServicioEcualizador extends ServicioEcualizador { + FakeServicioEcualizador({ + PresetEcualizador? principal, + Map? porEmisora, + }) : _config = ConfiguracionEcualizador( + principal: principal ?? PresetEcualizador.flat, + porEmisora: porEmisora ?? {}, + ); + + ConfiguracionEcualizador _config; + + @override + Future cargar() async => _config; + + @override + Future guardarPrincipal(PresetEcualizador preset) async { + _config = ConfiguracionEcualizador( + principal: preset, + porEmisora: _config.porEmisora, + ); + } + + @override + Future guardarPorEmisora(String uuid, PresetEcualizador preset) async { + final mapa = Map.from(_config.porEmisora); + mapa[uuid] = preset; + _config = ConfiguracionEcualizador( + principal: _config.principal, + porEmisora: mapa, + ); + } + + @override + Future eliminarPorEmisora(String uuid) async { + final mapa = Map.from(_config.porEmisora); + mapa.remove(uuid); + _config = ConfiguracionEcualizador( + principal: _config.principal, + porEmisora: mapa, + ); + } +} + +Emisora emisoraDemo({ + required String uuid, + required String nombre, + String url = 'https://stream.demo/radio', +}) { + return Emisora(uuid: uuid, nombre: nombre, url: url); +} diff --git a/test/pantallas/pantalla_inicio_test.dart b/test/pantallas/pantalla_inicio_test.dart new file mode 100644 index 0000000..b895da1 --- /dev/null +++ b/test/pantallas/pantalla_inicio_test.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; +import 'package:pluriwave/pantallas/pantalla_inicio.dart'; +import 'package:provider/provider.dart'; + +import '../helpers/fakes.dart'; + +void main() { + testWidgets( + 'PantallaInicio muestra custom, reproducir usa EstadoRadio y favorito usa flujo existente', + (tester) async { + final audio = FakeServicioAudio(); + final favoritos = FakeServicioFavoritos(); + final radio = FakeServicioRadio(); + final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); + final archivo = await _crearArchivoCustom([custom]); + final estado = EstadoRadio( + audio: audio, + favoritos: favoritos, + radio: radio, + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + await estado.inicializar(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: const MaterialApp( + home: Scaffold(body: PantallaInicio()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Custom Uno'), findsOneWidget); + + await tester.tap(find.text('Custom Uno')); + await tester.pumpAndSettle(); + expect(audio.emisorasReproducidas.map((e) => e.uuid), contains('custom-1')); + expect(radio.ultimoUuidClick, 'custom-1'); + + final tarjetaCustom = find.ancestor( + of: find.text('Custom Uno'), + matching: find.byType(Card), + ); + final botonFavorito = find.descendant( + of: tarjetaCustom.first, + matching: find.byIcon(Icons.favorite_outline_rounded), + ); + expect(botonFavorito, findsOneWidget); + + await tester.tap(botonFavorito); + await tester.pumpAndSettle(); + + expect(favoritos.toggleCalls, 1); + expect(await favoritos.esFavorito(custom.uuid), isTrue); + }); + + testWidgets( + 'PantallaInicio permite reintentar manualmente tras fallo inicial agotado', + (tester) async { + final radio = FakeServicioRadio( + erroresPopularesPorLlamada: [Exception('sin red')], + popularesPorLlamada: [ + const [], + [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], + ), + tendenciasPorLlamada: [ + const [], + [emisoraDemo(uuid: 'trend-1', nombre: 'Trend Uno')], + ], + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: radio, + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estado.inicializar(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: const MaterialApp( + home: Scaffold(body: PantallaInicio()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Sin conexión a la API de radio'), findsOneWidget); + expect(find.text('Reintentar'), findsOneWidget); + + await tester.tap(find.text('Reintentar')); + await tester.pumpAndSettle(); + + expect(radio.obtenerPopularesCalls, 2); + expect(find.text('Sin conexión a la API de radio'), findsNothing); + expect(find.text('API Uno'), findsOneWidget); + }); + + testWidgets('PantallaFavoritos muestra custom favorito tras recarga', + (tester) async { + final favoritos = FakeServicioFavoritos(); + final custom = emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'); + final archivo = await _crearArchivoCustom([custom]); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: favoritos, + radio: FakeServicioRadio( + populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], + ), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + await estado.inicializar(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: const MaterialApp( + home: Scaffold(body: PantallaInicio()), + ), + ), + ); + await tester.pumpAndSettle(); + + final tarjetaCustom = find.ancestor( + of: find.text('Custom Uno'), + matching: find.byType(Card), + ); + final botonFavorito = find.descendant( + of: tarjetaCustom.first, + matching: find.byIcon(Icons.favorite_outline_rounded), + ); + expect(botonFavorito, findsOneWidget); + + await tester.tap(botonFavorito); + await tester.pumpAndSettle(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: const MaterialApp( + home: Scaffold(body: PantallaFavoritos()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(await favoritos.esFavorito(custom.uuid), isTrue); + expect(find.text('Custom Uno'), findsOneWidget); + }); +} + +Future _crearArchivoCustom(List emisoras) async { + final dir = await Directory.systemTemp.createTemp('pluriwave-test-'); + final archivo = File('${dir.path}/emisoras_custom.json'); + await archivo.writeAsString(jsonEncode(emisoras.map((e) => e.toMap()).toList())); + return archivo; +} + +Future _archivoCustomVacio() async => _crearArchivoCustom(const []); diff --git a/test/servicios/servicio_radio_test.dart b/test/servicios/servicio_radio_test.dart new file mode 100644 index 0000000..950647c --- /dev/null +++ b/test/servicios/servicio_radio_test.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pluriwave/servicios/servicio_radio.dart'; + +void main() { + group('ServicioRadio retry + rotación', () { + test( + 'reintenta con otro host cuando el primero falla y recupera en el segundo', + () async { + final hostsSolicitados = []; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + hostsSolicitados.add(request.url.host); + if (request.url.host == 'host-1.api.radio-browser.info') { + return http.Response('fallo', 500); + } + return http.Response( + jsonEncode([ + { + 'stationuuid': 'uuid-ok', + 'name': 'Radio Recuperada', + 'url_resolved': 'https://stream.recuperada/audio', + }, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + servidores: const [ + 'host-1.api.radio-browser.info', + 'host-2.api.radio-browser.info', + ], + maxIntentos: 3, + retryDelay: Duration.zero, + ); + + final emisoras = await servicio.obtenerPopulares(limit: 1); + + expect(emisoras, hasLength(1)); + expect(emisoras.first.uuid, 'uuid-ok'); + expect( + hostsSolicitados, + equals([ + 'host-1.api.radio-browser.info', + 'host-2.api.radio-browser.info', + ]), + ); + }); + + test('corta al llegar al tope de intentos y propaga error final', () async { + var intentos = 0; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + intentos += 1; + throw http.ClientException('sin red', request.url); + }), + servidores: const ['host-unico.api.radio-browser.info'], + maxIntentos: 2, + retryDelay: Duration.zero, + ); + + expect( + () => servicio.obtenerPopulares(limit: 1), + throwsA(isA()), + ); + expect(intentos, 2); + }); + }); +}