fix(radio): quarantine corrupt custom-station files instead of silently emptying them
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s

A single malformed custom-station entry (missing uuid/url) used to wipe
the ENTIRE list on next load, and an unparseable file was treated the
same as an unreadable one -- both destroyed the user's saved stations
with no way to recover the original bytes.

Custom stations now parse per-entry via the shared persistencia_tolerante
helper (survivors kept, bad entries skipped+logged); a file that reads
but fails to decode is quarantined into a `.corrupt` sidecar instead of
being dropped, clearing the live path so the next add/remove starts
fresh. A file that cannot be READ at the OS level is left untouched and
a _customDegradado flag suppresses writes for the session -- unlike
alarms, this suppression is intentionally not lifted by an explicit
add/remove, since the file may still be intact on disk.
This commit is contained in:
2026-07-11 12:45:52 +02:00
parent a34182fdaf
commit 13ad736917
2 changed files with 315 additions and 13 deletions
+220
View File
@@ -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<bool> Function()? exists,
Future<String> Function()? readAsString,
Future<File> Function(String contenido)? writeAsString,
Future<File> Function(String nuevoPath)? rename,
}) : _path = path,
_existsImpl = exists,
_readAsStringImpl = readAsString,
_writeAsStringImpl = writeAsString,
_renameImpl = rename;
final String _path;
final Future<bool> Function()? _existsImpl;
final Future<String> Function()? _readAsStringImpl;
final Future<File> Function(String contenido)? _writeAsStringImpl;
final Future<File> Function(String nuevoPath)? _renameImpl;
int writeAsStringCalls = 0;
@override
String get path => _path;
@override
Future<bool> exists() => (_existsImpl ?? () async => true)();
@override
Future<String> readAsString({Encoding encoding = utf8}) =>
(_readAsStringImpl ?? () async => '')();
@override
Future<File> writeAsString(
String contents, {
FileMode mode = FileMode.write,
Encoding encoding = utf8,
bool flush = false,
}) {
writeAsStringCalls++;
return (_writeAsStringImpl ?? (_) async => this)(contents);
}
@override
Future<File> rename(String newPath) =>
(_renameImpl ?? (_) async => this)(newPath);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _AudioControlado extends ServicioAudio {
@@ -416,3 +625,14 @@ Future<File> _crearArchivoCustom(List<Emisora> 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<File> _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;
}