fix(eq): keep valid presets when stored maps are partially corrupt
Convert the 4 EQ persistence readers (device presets, matrix presets, device names, per-station presets) to per-entry tolerant parsing via the shared persistencia_tolerante helper, so one corrupt entry no longer discards every sibling preset. The principal-preset reader gains diagnostic logging on its existing fallback path. No degraded flag or quarantine here (unlike alarms/stations) since EQ writes are explicit-only and presets are trivially re-creatable.
This commit is contained in:
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../modelos/preset_ecualizador.dart';
|
import '../modelos/preset_ecualizador.dart';
|
||||||
|
import 'persistencia_tolerante.dart';
|
||||||
|
|
||||||
class ConfiguracionEcualizador {
|
class ConfiguracionEcualizador {
|
||||||
const ConfiguracionEcualizador({
|
const ConfiguracionEcualizador({
|
||||||
@@ -227,7 +228,14 @@ class ServicioEcualizador {
|
|||||||
await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres);
|
await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads a `Map<String, PresetEcualizador>` from a SharedPreferences JSON key.
|
/// Reads a `Map<String, PresetEcualizador>` 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<String, PresetEcualizador> _leerMapa(
|
Map<String, PresetEcualizador> _leerMapa(
|
||||||
SharedPreferences prefs,
|
SharedPreferences prefs,
|
||||||
String key,
|
String key,
|
||||||
@@ -236,13 +244,21 @@ class ServicioEcualizador {
|
|||||||
if (raw == null || raw.isEmpty) return {};
|
if (raw == null || raw.isEmpty) return {};
|
||||||
try {
|
try {
|
||||||
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||||
return data.map(
|
final resultado = parseMapaTolerante<PresetEcualizador>(
|
||||||
(k, v) => MapEntry(
|
data,
|
||||||
k,
|
(valor) => PresetEcualizador.desdeJson(
|
||||||
PresetEcualizador.desdeJson(Map<String, dynamic>.from(v as Map)),
|
Map<String, dynamic>.from(valor as Map),
|
||||||
),
|
),
|
||||||
|
subsistema: 'ecualizador',
|
||||||
|
coleccion: key,
|
||||||
|
);
|
||||||
|
return resultado.validas;
|
||||||
|
} catch (e) {
|
||||||
|
registrarSaltoPersistencia(
|
||||||
|
subsistema: 'ecualizador',
|
||||||
|
detalle: key,
|
||||||
|
razon: e.toString(),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,14 +272,29 @@ class ServicioEcualizador {
|
|||||||
await prefs.setString(key, jsonEncode(serializado));
|
await prefs.setString(key, jsonEncode(serializado));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads a `Map<String, String>` from a SharedPreferences JSON key.
|
/// Reads a `Map<String, String>` 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<String, String> _leerMapaStrings(SharedPreferences prefs, String key) {
|
Map<String, String> _leerMapaStrings(SharedPreferences prefs, String key) {
|
||||||
final raw = prefs.getString(key);
|
final raw = prefs.getString(key);
|
||||||
if (raw == null || raw.isEmpty) return {};
|
if (raw == null || raw.isEmpty) return {};
|
||||||
try {
|
try {
|
||||||
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||||
return data.map((k, v) => MapEntry(k, v as String));
|
final resultado = parseMapaTolerante<String>(
|
||||||
} catch (_) {
|
data,
|
||||||
|
(valor) => valor as String,
|
||||||
|
subsistema: 'ecualizador',
|
||||||
|
coleccion: key,
|
||||||
|
);
|
||||||
|
return resultado.validas;
|
||||||
|
} catch (e) {
|
||||||
|
registrarSaltoPersistencia(
|
||||||
|
subsistema: 'ecualizador',
|
||||||
|
detalle: key,
|
||||||
|
razon: e.toString(),
|
||||||
|
);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +307,10 @@ class ServicioEcualizador {
|
|||||||
await prefs.setString(key, jsonEncode(mapa));
|
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) {
|
PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) {
|
||||||
final raw = prefs.getString(_keyPresetPrincipal);
|
final raw = prefs.getString(_keyPresetPrincipal);
|
||||||
if (raw == null || raw.isEmpty) {
|
if (raw == null || raw.isEmpty) {
|
||||||
@@ -285,11 +320,20 @@ class ServicioEcualizador {
|
|||||||
return PresetEcualizador.desdeJson(
|
return PresetEcualizador.desdeJson(
|
||||||
Map<String, dynamic>.from(jsonDecode(raw) as Map),
|
Map<String, dynamic>.from(jsonDecode(raw) as Map),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
registrarSaltoPersistencia(
|
||||||
|
subsistema: 'ecualizador',
|
||||||
|
detalle: _keyPresetPrincipal,
|
||||||
|
razon: e.toString(),
|
||||||
|
);
|
||||||
return PresetEcualizador.flat;
|
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<String, PresetEcualizador> _leerPresetsPorEmisora(
|
Map<String, PresetEcualizador> _leerPresetsPorEmisora(
|
||||||
SharedPreferences prefs,
|
SharedPreferences prefs,
|
||||||
) {
|
) {
|
||||||
@@ -299,13 +343,21 @@ class ServicioEcualizador {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||||
return data.map(
|
final resultado = parseMapaTolerante<PresetEcualizador>(
|
||||||
(uuid, preset) => MapEntry(
|
data,
|
||||||
uuid,
|
(valor) => PresetEcualizador.desdeJson(
|
||||||
PresetEcualizador.desdeJson(Map<String, dynamic>.from(preset as Map)),
|
Map<String, dynamic>.from(valor as Map),
|
||||||
),
|
),
|
||||||
|
subsistema: 'ecualizador',
|
||||||
|
coleccion: _keyPresetsPorEmisora,
|
||||||
|
);
|
||||||
|
return resultado.validas;
|
||||||
|
} catch (e) {
|
||||||
|
registrarSaltoPersistencia(
|
||||||
|
subsistema: 'ecualizador',
|
||||||
|
detalle: _keyPresetsPorEmisora,
|
||||||
|
razon: e.toString(),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
Map<String, dynamic> _presetJson(String nombre) => {
|
||||||
|
'nombre': nombre,
|
||||||
|
'bandas': [1.0, 2.0, 3.0, 4.0, 5.0],
|
||||||
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
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<String> 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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user