diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index 83e6a7d..1f407a6 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../modelos/preset_ecualizador.dart'; +import 'persistencia_tolerante.dart'; class ConfiguracionEcualizador { const ConfiguracionEcualizador({ @@ -227,7 +228,14 @@ class ServicioEcualizador { await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres); } - /// Reads a `Map` from a SharedPreferences JSON key. + /// Reads a `Map` from a SharedPreferences JSON + /// key with per-entry tolerance (persistence-resilience D6): a value that + /// fails to parse is skipped and logged, its sibling entries survive and + /// every surviving key (including the matrix's colon-delimited + /// `stationUuid:deviceId` keys) is preserved byte-for-byte. A top-level + /// decode failure still degrades the whole map to empty (now logged too); + /// no flag/quarantine here — EQ presets are explicit-only writes and + /// trivially re-creatable (D6, intentional asymmetry vs. Alarms/Stations). Map _leerMapa( SharedPreferences prefs, String key, @@ -236,13 +244,21 @@ class ServicioEcualizador { if (raw == null || raw.isEmpty) return {}; try { final data = Map.from(jsonDecode(raw) as Map); - return data.map( - (k, v) => MapEntry( - k, - PresetEcualizador.desdeJson(Map.from(v as Map)), + final resultado = parseMapaTolerante( + data, + (valor) => PresetEcualizador.desdeJson( + Map.from(valor as Map), ), + subsistema: 'ecualizador', + coleccion: key, + ); + return resultado.validas; + } catch (e) { + registrarSaltoPersistencia( + subsistema: 'ecualizador', + detalle: key, + razon: e.toString(), ); - } catch (_) { return {}; } } @@ -256,14 +272,29 @@ class ServicioEcualizador { await prefs.setString(key, jsonEncode(serializado)); } - /// Reads a `Map` from a SharedPreferences JSON key. + /// Reads a `Map` from a SharedPreferences JSON key with + /// per-entry tolerance (persistence-resilience D6): a value that is not a + /// String is skipped and logged, sibling entries and keys survive + /// untouched. A top-level decode failure still degrades the whole map to + /// empty (now logged too); no flag/quarantine here (D6). Map _leerMapaStrings(SharedPreferences prefs, String key) { final raw = prefs.getString(key); if (raw == null || raw.isEmpty) return {}; try { final data = Map.from(jsonDecode(raw) as Map); - return data.map((k, v) => MapEntry(k, v as String)); - } catch (_) { + final resultado = parseMapaTolerante( + data, + (valor) => valor as String, + subsistema: 'ecualizador', + coleccion: key, + ); + return resultado.validas; + } catch (e) { + registrarSaltoPersistencia( + subsistema: 'ecualizador', + detalle: key, + razon: e.toString(), + ); return {}; } } @@ -276,6 +307,10 @@ class ServicioEcualizador { await prefs.setString(key, jsonEncode(mapa)); } + /// Reads the single principal-preset key. Single-value (not a collection) + /// so there is nothing to skip per-entry; a decode failure still falls + /// back to [PresetEcualizador.flat] (unchanged behavior) but is now + /// logged too (persistence-resilience D6, diagnostics). PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) { final raw = prefs.getString(_keyPresetPrincipal); if (raw == null || raw.isEmpty) { @@ -285,11 +320,20 @@ class ServicioEcualizador { return PresetEcualizador.desdeJson( Map.from(jsonDecode(raw) as Map), ); - } catch (_) { + } catch (e) { + registrarSaltoPersistencia( + subsistema: 'ecualizador', + detalle: _keyPresetPrincipal, + razon: e.toString(), + ); return PresetEcualizador.flat; } } + /// Reads the per-station preset map with per-entry tolerance + /// (persistence-resilience D6) via the same shared helper as [_leerMapa]; + /// a station whose preset value fails to parse is skipped and logged, its + /// siblings survive under their original uuid keys. Map _leerPresetsPorEmisora( SharedPreferences prefs, ) { @@ -299,13 +343,21 @@ class ServicioEcualizador { } try { final data = Map.from(jsonDecode(raw) as Map); - return data.map( - (uuid, preset) => MapEntry( - uuid, - PresetEcualizador.desdeJson(Map.from(preset as Map)), + final resultado = parseMapaTolerante( + data, + (valor) => PresetEcualizador.desdeJson( + Map.from(valor as Map), ), + subsistema: 'ecualizador', + coleccion: _keyPresetsPorEmisora, + ); + return resultado.validas; + } catch (e) { + registrarSaltoPersistencia( + subsistema: 'ecualizador', + detalle: _keyPresetsPorEmisora, + razon: e.toString(), ); - } catch (_) { return {}; } } diff --git a/test/servicios/servicio_ecualizador_test.dart b/test/servicios/servicio_ecualizador_test.dart index faf9eb6..0aa8e8b 100644 --- a/test/servicios/servicio_ecualizador_test.dart +++ b/test/servicios/servicio_ecualizador_test.dart @@ -1,8 +1,16 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_ecualizador.dart'; import 'package:shared_preferences/shared_preferences.dart'; +Map _presetJson(String nombre) => { + 'nombre': nombre, + 'bandas': [1.0, 2.0, 3.0, 4.0, 5.0], +}; + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -406,4 +414,160 @@ void main() { ); }); }); + + // --------------------------------------------------------------------------- + // persistence-resilience Phase C: per-entry tolerant reads for the 4 EQ + // map/single-value readers + degraded-read logging. NO flag/quarantine + // here (D6, intentional asymmetry vs. Alarms/Stations) — EQ presets are + // explicit-only writes and trivially re-creatable. + // --------------------------------------------------------------------------- + + group( + 'ServicioEcualizador — lectura tolerante y diagnostico (persistence-resilience)', + () { + late DebugPrintCallback debugPrintOriginal; + late List logs; + + setUp(() { + debugPrintOriginal = debugPrint; + logs = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + }); + + tearDown(() { + debugPrint = debugPrintOriginal; + }); + + test( + 'C2 presetsMatriz: sobrevive parcial con 2 entradas validas + 1 valor ' + 'corrupto, claves identicas al original', + () async { + final raw = jsonEncode({ + 'station-1:device-a': _presetJson('Rock'), + 'station-2:device-b': _presetJson('Pop'), + 'station-3:device-c': {'nombre': 'Malo', 'bandas': 'no-es-lista'}, + }); + SharedPreferences.setMockInitialValues({ + 'eq_presets_matriz_v1': raw, + }); + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + final config = await servicio.cargar(); + + expect(config.presetsMatriz.keys.toSet(), { + 'station-1:device-a', + 'station-2:device-b', + }); + expect(config.presetsMatriz['station-1:device-a']!.nombre, 'Rock'); + expect(config.presetsMatriz['station-2:device-b']!.nombre, 'Pop'); + }, + ); + + test( + 'C3 presetsMatriz: clave sobreviviente con deviceId estilo MAC ' + '(varios dos puntos) queda intacta pese a un vecino corrupto', + () async { + const claveMac = 'station-X:bt_a2dp:AA:BB:CC:DD:EE:FF'; + final raw = jsonEncode({ + claveMac: _presetJson('Jazz'), + 'station-Y:wired_headset': _presetJson('Rock'), + 'station-Z:corrupt': {'nombre': 'Malo', 'bandas': 'no-es-lista'}, + }); + SharedPreferences.setMockInitialValues({ + 'eq_presets_matriz_v1': raw, + }); + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + final config = await servicio.cargar(); + + expect(config.presetsMatriz.keys.toSet(), { + claveMac, + 'station-Y:wired_headset', + }); + expect(config.presetsMatriz[claveMac]!.nombre, 'Jazz'); + }, + ); + + test( + 'C4 presetsDispositivo totalmente corrupto: cargar() queda vacio, ' + 'guardarPresetDispositivo sigue escribiendo (sin bandera en EQ)', + () async { + SharedPreferences.setMockInitialValues({ + 'eq_preset_por_dispositivo_v1': '{bad', + }); + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + final config = await servicio.cargar(); + expect(config.presetsDispositivo, isEmpty); + + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + await servicio.guardarPresetDispositivo( + deviceId, + PresetEcualizador.rock, + ); + final tras = await servicio.cargar(); + + expect( + tras.presetsDispositivo[deviceId], + equals(PresetEcualizador.rock), + ); + }, + ); + + test( + 'C5 preset principal totalmente corrupto: cargar() retorna flat y ' + 'loguea la lectura degradada', + () async { + SharedPreferences.setMockInitialValues({ + 'eq_preset_principal_v1': '{bad', + }); + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + final config = await servicio.cargar(); + + expect(config.principal, equals(PresetEcualizador.flat)); + expect( + logs.any((l) => l.startsWith('[PluriWave][persistencia]')), + isTrue, + reason: + 'una lectura degradada del preset principal debe quedar ' + 'registrada (antes silenciosa)', + ); + }, + ); + + test( + 'C6 nombresDispositivos: sobrevive parcial con 2 nombres validos + 1 ' + 'valor corrupto', + () async { + final raw = jsonEncode({ + 'bt_a2dp:AA:BB': 'Living Room BT', + 'wired_headset': 'Office Headset', + 'builtin_speaker': 12345, // not a String -> must be skipped + }); + SharedPreferences.setMockInitialValues({ + 'eq_nombres_dispositivos_v1': raw, + }); + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + + final config = await servicio.cargar(); + + expect( + config.nombresDispositivos, + equals({ + 'bt_a2dp:AA:BB': 'Living Room BT', + 'wired_headset': 'Office Headset', + }), + ); + }, + ); + }, + ); }