diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 5269b0e..7a53953 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -16,6 +16,7 @@ import 'estado_busqueda.dart'; import 'estado_ecualizador.dart'; import 'estado_grabacion.dart'; import 'orden_emisoras.dart'; +import '../servicios/persistencia_tolerante.dart'; import '../servicios/servicio_audio.dart'; import '../servicios/servicio_dispositivo_audio.dart'; import '../servicios/servicio_ecualizador.dart'; @@ -133,6 +134,18 @@ class EstadoRadio extends ChangeNotifier { List _gruposFavoritos = []; List _emisorasCustom = []; + // persistence-resilience (D5): set when the custom-stations file EXISTS + // but could not be READ at the OS level (vs. a parse failure, which + // quarantines the file instead -- see _ponerEnCuarentena). While true, + // _guardarEmisorasCustom is suppressed for the rest of this session: we + // cannot tell whether the underlying file is actually intact, so we never + // risk clobbering it with an empty in-memory list. Unlike the Alarms + // degraded flag, this is intentionally NOT cleared by an explicit + // add/remove -- only the NEXT clean/partial load in _cargarEmisorasCustom + // clears it (D5's asymmetry: a transiently-unreadable file may still be + // intact on disk). + bool _customDegradado = false; + bool _cargandoPopulares = false; String? _errorCarga; @@ -500,27 +513,96 @@ class EstadoRadio extends ChangeNotifier { return File('${dir.path}/emisoras_custom.json'); } + /// Loads the custom-stations file with per-entry tolerance + /// (persistence-resilience D1/D5). Two DIFFERENT failure kinds get TWO + /// different treatments because they carry different guarantees about + /// whether the file itself is still intact: + /// - the file cannot be READ at the OS level (see [_leerContenidoCustom]) + /// -> IO-fail: unknown whether the file is intact, so it is left + /// completely untouched and [_customDegradado] suppresses writes. + /// - the file reads fine but its top-level JSON/shape is invalid -> the + /// bytes we DID manage to read are definitely the corrupt culprit, so + /// they are quarantined into a `.corrupt` sidecar and the live path is + /// cleared for the next write ([_ponerEnCuarentena]); no flag needed, + /// the cleared path is itself the "authority restored" signal. + /// - per-entry failures inside an otherwise-valid list are handled by the + /// shared [parseListaTolerante] (D1): survivors are kept, no flag/ + /// quarantine at all. Future _cargarEmisorasCustom() async { - try { - final archivo = await _archivoCustom(); - if (!await archivo.exists()) { - _emisorasCustom = []; - notifyListeners(); - return; - } + final archivo = await _archivoCustom(); + final contenido = await _leerContenidoCustom(archivo); + if (contenido == null) return; // ya resuelto: vacio o degradado por IO. - final data = jsonDecode(await archivo.readAsString()) as List; - _emisorasCustom = - data - .map((e) => Emisora.fromMap(Map.from(e as Map))) - .toList(); - } catch (_) { + try { + final data = jsonDecode(contenido) as List; + final resultado = parseListaTolerante( + data, + Emisora.fromMap, + subsistema: 'emisoras_custom', + coleccion: 'emisoras_custom', + ); + _emisorasCustom = resultado.validas; + _customDegradado = false; + } catch (e) { + await _ponerEnCuarentena(archivo); _emisorasCustom = []; + registrarSaltoPersistencia( + subsistema: 'emisoras_custom', + detalle: archivo.path, + razon: e.toString(), + ); } notifyListeners(); } + /// Reads the raw content of [archivo]; returns null when the outcome was + /// already fully resolved here, so the caller has nothing left to parse: + /// - the file does not exist -> healthy empty state (unchanged behavior). + /// - `exists()`/`readAsString()` throws -> IO-fail (D5): the file is left + /// untouched (we cannot know if it is actually intact) and + /// [_customDegradado] suppresses [_guardarEmisorasCustom] for the rest + /// of this session. + Future _leerContenidoCustom(File archivo) async { + try { + if (!await archivo.exists()) { + _emisorasCustom = []; + _customDegradado = false; + notifyListeners(); + return null; + } + return await archivo.readAsString(); + } catch (e) { + _emisorasCustom = []; + _customDegradado = true; + registrarSaltoPersistencia( + subsistema: 'emisoras_custom', + detalle: archivo.path, + razon: e.toString(), + ); + notifyListeners(); + return null; + } + } + + /// Moves an unparseable custom-stations file out of the live path so the + /// next add/remove starts fresh. If a `.corrupt` sidecar from a PREVIOUS + /// quarantine already exists, that earlier payload is preserved untouched + /// and the newly-corrupt live file is simply dropped (D5) -- a sidecar + /// only ever holds the OLDEST unresolved quarantine, never overwritten by + /// a newer one. + Future _ponerEnCuarentena(File archivo) async { + final sidecar = File('${archivo.path}.corrupt'); + if (await sidecar.exists()) { + await archivo.delete(); + } else { + await archivo.rename(sidecar.path); + } + } + Future _guardarEmisorasCustom() async { + // persistence-resilience (D5): never write while an IO-degraded read + // means we cannot be sure the on-disk file is still intact. + if (_customDegradado) return; final archivo = await _archivoCustom(); await archivo.writeAsString( jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()), diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index 7d4d3b7..451b49c 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -370,6 +370,215 @@ void main() { expect(estado.listaFavoritos.first.grupoFavoritosId, 'sin_asignar'); }); }); + + group( + 'EstadoRadio — emisoras custom: lectura tolerante y guardia de ' + 'degradacion (persistence-resilience)', + () { + test( + 'entradas invalidas se omiten sin perder las validas ni fabricar ' + 'uuid (D5 parcial)', + () async { + final archivo = await _crearArchivoCustomRaw( + jsonEncode([ + {'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'}, + {'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'}, + // falta 'url' (campo requerido) -> Emisora.fromMap lanza. + {'uuid': 'custom-3', 'nombre': 'Sin url'}, + // falta 'uuid' -> Emisora.fromMap lanza. + {'nombre': 'Sin uuid', 'url': 'http://d'}, + ]), + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + + expect(estado.emisorasCustom, hasLength(2)); + expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), { + 'custom-1', + 'custom-2', + }); + }, + ); + + test( + 'JSON invalido al nivel superior pone en cuarentena el archivo ' + 'original (D5 parse-fail)', + () async { + final archivo = await _crearArchivoCustomRaw('{bad'); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + + expect(estado.emisorasCustom, isEmpty); + final sidecar = File('${archivo.path}.corrupt'); + expect(await sidecar.exists(), isTrue); + expect(await sidecar.readAsString(), '{bad'); + expect(await archivo.exists(), isFalse); + }, + ); + + test( + 'agregar tras la cuarentena escribe solo la nueva emisora y no ' + 'toca el sidecar (D5, autoridad de escritura restaurada)', + () async { + final archivo = await _crearArchivoCustomRaw('{bad'); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + await estado.inicializar(); + final sidecar = File('${archivo.path}.corrupt'); + final sidecarPrevio = await sidecar.readAsString(); + + final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva'); + await estado.agregarEmisoraCustom(nueva); + + expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']); + final contenidoVivo = + jsonDecode(await archivo.readAsString()) as List; + expect(contenidoVivo, hasLength(1)); + expect((contenidoVivo.single as Map)['uuid'], 'nueva-1'); + expect(await sidecar.readAsString(), sidecarPrevio); + }, + ); + + test( + 'fallo de IO al leer suprime la escritura y no se restaura con un ' + 'alta explicita (D5 IO-fail)', + () async { + final espia = _ArchivoEspia( + path: '/fake/emisoras_custom.json', + exists: () async => true, + readAsString: + () async => throw const FileSystemException( + 'fallo simulado de lectura', + ), + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => espia, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + expect(estado.emisorasCustom, isEmpty); + + await estado.agregarEmisoraCustom( + emisoraDemo(uuid: 'nueva-x', nombre: 'X'), + ); + + expect( + estado.emisorasCustom.map((e) => e.uuid), + contains('nueva-x'), + ); + expect(espia.writeAsStringCalls, 0); + }, + ); + + test( + 'si ya existe un sidecar .corrupt no lo pisa, solo limpia el ' + 'archivo vivo (D5)', + () async { + final archivo = await _crearArchivoCustomRaw('{bad-nuevo'); + final sidecar = File('${archivo.path}.corrupt'); + await sidecar.writeAsString('contenido-previo-X'); + + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + + expect(await sidecar.readAsString(), 'contenido-previo-X'); + expect(await archivo.exists(), isFalse); + expect(estado.emisorasCustom, isEmpty); + }, + ); + }, + ); +} + +/// [File] spy: only the members `_cargarEmisorasCustom`/ +/// `_guardarEmisorasCustom` touch are implemented (with injectable +/// overrides); everything else throws via noSuchMethod. Mirrors +/// servicio_alarmas_corrupcion_test.dart's `_PrefsEspia` pattern, adapted +/// to force an IO failure independent of the host OS (persistence- +/// resilience D5). +class _ArchivoEspia implements File { + _ArchivoEspia({ + required String path, + Future Function()? exists, + Future Function()? readAsString, + Future Function(String contenido)? writeAsString, + Future Function(String nuevoPath)? rename, + }) : _path = path, + _existsImpl = exists, + _readAsStringImpl = readAsString, + _writeAsStringImpl = writeAsString, + _renameImpl = rename; + + final String _path; + final Future Function()? _existsImpl; + final Future Function()? _readAsStringImpl; + final Future Function(String contenido)? _writeAsStringImpl; + final Future Function(String nuevoPath)? _renameImpl; + + int writeAsStringCalls = 0; + + @override + String get path => _path; + + @override + Future exists() => (_existsImpl ?? () async => true)(); + + @override + Future readAsString({Encoding encoding = utf8}) => + (_readAsStringImpl ?? () async => '')(); + + @override + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + writeAsStringCalls++; + return (_writeAsStringImpl ?? (_) async => this)(contents); + } + + @override + Future rename(String newPath) => + (_renameImpl ?? (_) async => this)(newPath); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _AudioControlado extends ServicioAudio { @@ -416,3 +625,14 @@ Future _crearArchivoCustom(List emisoras) async { ); return archivo; } + +/// Writes RAW (already-encoded) [contenido] to a fresh temp +/// `emisoras_custom.json` -- unlike [_crearArchivoCustom], this bypasses +/// `Emisora.toMap` so tests can seed malformed/partially-invalid JSON that +/// a real Emisora could never produce (persistence-resilience D5). +Future _crearArchivoCustomRaw(String contenido) async { + final dir = await Directory.systemTemp.createTemp('pluriwave-test-'); + final archivo = File('${dir.path}/emisoras_custom.json'); + await archivo.writeAsString(contenido); + return archivo; +}