diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart index 79fc13c..9384cde 100644 --- a/lib/modelos/alarma_musical.dart +++ b/lib/modelos/alarma_musical.dart @@ -142,6 +142,14 @@ class AlarmaMusical { 'actualizadaEn': actualizadaEn?.toIso8601String(), }; + // persistence-resilience (D2): `id` stays a REQUIRED, un-defaulted cast + // on purpose -- a missing/wrong-type id must throw, not fall back to a + // fabricated value. Callers that read persisted collections (e.g. + // ServicioAlarmas._parsear via persistencia_tolerante.dart) wrap each + // fromJson call in a per-entry try: a thrown entry is skipped and + // logged, never replacing this required field with a sentinel/fabricated + // id ("skip-never-fabricate"). This boundary also tolerates any future + // required-field break the same way, not just id. factory AlarmaMusical.fromJson(Map json) { return AlarmaMusical( id: json['id'] as String, diff --git a/lib/servicios/persistencia_tolerante.dart b/lib/servicios/persistencia_tolerante.dart new file mode 100644 index 0000000..c0603bf --- /dev/null +++ b/lib/servicios/persistencia_tolerante.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart'; + +/// Shared per-entry tolerant-parse primitives (persistence-resilience D1). +/// +/// Every subsystem that persists a collection (alarms, custom stations, EQ +/// presets/device names) decodes its own top-level payload first — total +/// top-level decode failure is a subsystem-specific policy (see each +/// caller's own degraded-read handling) and is intentionally OUT of scope +/// here. What IS shared is what happens once the container decoded fine but +/// one entry inside it did not: that single entry must be skipped and +/// logged, never allowed to take its valid siblings down with it, and never +/// replaced by a fabricated identity. + +/// Result of a tolerant, per-entry parse pass over a persisted collection. +/// +/// [validas] holds whatever survived (a `List` for +/// [parseListaTolerante], a `Map` for [parseMapaTolerante]). +/// [saltadas] counts how many entries were skipped because they failed to +/// parse — callers use this to detect a PARTIAL read (>0) versus a fully +/// healthy one (0) without inspecting [validas] itself. +typedef ResultadoTolerante = ({T validas, int saltadas}); + +/// Parses each entry of the ALREADY-decoded [datos] independently via +/// [parser]; an entry that is not a [Map], or whose [parser] throws (e.g. a +/// missing/invalid identity field), is skipped and logged instead of +/// aborting the whole parse. Order of survivors matches [datos]' order. +/// +/// [datos] must already be the decoded top-level list (e.g. the result of +/// `jsonDecode(raw) as Map` -> `map['alarmas'] as List?`). +/// This helper never calls `jsonDecode` itself — total-failure policy for +/// an unparseable top-level payload differs per subsystem and stays with +/// the caller. +ResultadoTolerante> parseListaTolerante( + List? datos, + T Function(Map entrada) parser, { + required String subsistema, + required String coleccion, +}) { + if (datos == null || datos.isEmpty) { + return (validas: [], saltadas: 0); + } + final validas = []; + var saltadas = 0; + for (var indice = 0; indice < datos.length; indice++) { + try { + final entrada = datos[indice]; + if (entrada is! Map) { + throw const FormatException('la entrada no es un mapa'); + } + validas.add(parser(Map.from(entrada))); + } catch (e) { + saltadas++; + registrarSaltoPersistencia( + subsistema: subsistema, + detalle: '$coleccion[$indice]', + razon: e.toString(), + ); + } + } + return (validas: validas, saltadas: saltadas); +} + +/// Parses each VALUE of the ALREADY-decoded [datos] independently via +/// [parser]; a value whose [parser] throws is skipped and logged, and only +/// its key is dropped from the result. Keys of surviving entries are +/// preserved byte-for-byte (needed by callers whose keys carry meaning, +/// e.g. EQ's colon-delimited `stationUuid:deviceId` matrix keys). +/// +/// [datos] must already be the decoded top-level map (e.g. the result of +/// `jsonDecode(raw) as Map?`). This helper never calls +/// `jsonDecode` itself — see [parseListaTolerante] for the same rationale. +ResultadoTolerante> parseMapaTolerante( + Map? datos, + V Function(dynamic valor) parser, { + required String subsistema, + required String coleccion, +}) { + if (datos == null || datos.isEmpty) { + return (validas: {}, saltadas: 0); + } + final validas = {}; + var saltadas = 0; + for (final entrada in datos.entries) { + try { + validas[entrada.key] = parser(entrada.value); + } catch (e) { + saltadas++; + registrarSaltoPersistencia( + subsistema: subsistema, + detalle: '$coleccion[${entrada.key}]', + razon: e.toString(), + ); + } + } + return (validas: validas, saltadas: saltadas); +} + +/// Logs a persistence skip or degraded read with enough context to +/// diagnose it later: which subsystem, which entry/key/reason. Developer +/// facing only — this MUST NOT surface as user-visible copy or gain an +/// l10n key (persistence-resilience spec, "Diagnostics are developer-facing +/// only"). +void registrarSaltoPersistencia({ + required String subsistema, + required String detalle, + required String razon, +}) { + debugPrint('[PluriWave][persistencia] $subsistema $detalle: $razon'); +} diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index c8bb5db..3769420 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart'; import '../modelos/alarma_musical.dart'; import '../modelos/emisora.dart'; +import 'persistencia_tolerante.dart'; import 'servicio_programacion_alarmas.dart'; class ConfiguracionAlarmas { @@ -41,6 +42,14 @@ class ServicioAlarmas { String? _cacheRaw; Future _cola = Future.value(); + // persistence-resilience (D4): set when the top-level payload could not + // be decoded at all (vs. an individual entry inside it). While true, the + // AUTOMATIC writer (recalcularTodas) must never reach disk -- only an + // explicit user mutation (which always funnels through _guardar) may + // overwrite a payload we could not read. Cleared by _guardar and by the + // next successful (even if partial) read. + bool _lecturaAlarmasDegradada = false; + Future _enCola(Future Function() accion) { final resultado = _cola.then((_) => accion()); _cola = resultado.then((_) {}, onError: (_) {}); @@ -63,12 +72,29 @@ class ServicioAlarmas { final raw = prefs.getString(_keyConfig); final config = _parsear(raw); _cache = config; - _cacheRaw = raw; return config; } + /// Parses the persisted [raw] payload with per-entry tolerance + /// (persistence-resilience D1/D2) and updates [_cacheRaw] / + /// [_lecturaAlarmasDegradada] as a side effect, since each outcome needs + /// a DIFFERENT cached-raw value: + /// - top-level decode failure (bad JSON, or any container-level field of + /// the wrong shape) -> DEGRADED: empty config, [_cacheRaw] keeps the + /// corrupt [raw] untouched, [_lecturaAlarmasDegradada] set so + /// `recalcularTodas` never overwrites it (D4). + /// - decodes, but one or more entries are malformed -> PARTIAL: only the + /// survivors are kept, [_cacheRaw] is normalized to their own + /// serialization -- NOT the corrupt raw (D3) -- so the automatic + /// writer's dirty-guard compares against a coherent baseline instead of + /// re-firing on every 60s tick. + /// - fully healthy payload -> unchanged behavior; clears the flag if a + /// previous read had set it (suppression lifts on next successful + /// read). ConfiguracionAlarmas _parsear(String? raw) { if (raw == null || raw.trim().isEmpty) { + _lecturaAlarmasDegradada = false; + _cacheRaw = raw; return const ConfiguracionAlarmas( alarmas: [], vacaciones: [], @@ -77,30 +103,44 @@ class ServicioAlarmas { } try { final data = jsonDecode(raw) as Map; - return ConfiguracionAlarmas( - alarmas: - (data['alarmas'] as List? ?? const []) - .whereType() - .map( - (e) => AlarmaMusical.fromJson(Map.from(e)), - ) - .toList(), - vacaciones: - (data['vacaciones'] as List? ?? const []) - .whereType() - .map( - (e) => RangoVacaciones.fromJson(Map.from(e)), - ) - .toList(), - excepciones: - (data['excepciones'] as List? ?? const []) - .whereType() - .map( - (e) => ExcepcionAlarma.fromJson(Map.from(e)), - ) - .toList(), + final alarmas = parseListaTolerante( + data['alarmas'] as List?, + AlarmaMusical.fromJson, + subsistema: 'alarmas', + coleccion: 'alarmas', + ); + final vacaciones = parseListaTolerante( + data['vacaciones'] as List?, + RangoVacaciones.fromJson, + subsistema: 'alarmas', + coleccion: 'vacaciones', + ); + final excepciones = parseListaTolerante( + data['excepciones'] as List?, + ExcepcionAlarma.fromJson, + subsistema: 'alarmas', + coleccion: 'excepciones', + ); + final config = ConfiguracionAlarmas( + alarmas: alarmas.validas, + vacaciones: vacaciones.validas, + excepciones: excepciones.validas, + ); + _lecturaAlarmasDegradada = false; + final huboSaltos = + alarmas.saltadas > 0 || + vacaciones.saltadas > 0 || + excepciones.saltadas > 0; + _cacheRaw = huboSaltos ? _serializar(config) : raw; + return config; + } catch (e) { + _lecturaAlarmasDegradada = true; + _cacheRaw = raw; + registrarSaltoPersistencia( + subsistema: 'alarmas', + detalle: _keyConfig, + razon: e.toString(), ); - } catch (_) { return const ConfiguracionAlarmas( alarmas: [], vacaciones: [], @@ -200,6 +240,11 @@ class ServicioAlarmas { Future recalcularTodas() => _enCola(() async { final config = await _configActual(); + // persistence-resilience (D4): a degraded top-level read must never + // let this AUTOMATIC writer reach disk -- only an explicit user + // mutation (guardarAlarma/eliminarAlarma/etc., via _guardar) may + // persist over a payload we could not read. + if (_lecturaAlarmasDegradada) return config; final alarmas = _recalcularLista( config.alarmas, config.vacaciones, @@ -427,6 +472,10 @@ class ServicioAlarmas { await prefs.setString(_keyConfig, serializado); _cache = config; _cacheRaw = serializado; + // persistence-resilience (D4): every explicit mutation funnels through + // here, so this is where write authority is restored after a degraded + // read -- user intent wins over a suppressed automatic writer. + _lecturaAlarmasDegradada = false; } String _serializar(ConfiguracionAlarmas config) => jsonEncode({ diff --git a/test/servicios/persistencia_tolerante_test.dart b/test/servicios/persistencia_tolerante_test.dart new file mode 100644 index 0000000..3f8039b --- /dev/null +++ b/test/servicios/persistencia_tolerante_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/persistencia_tolerante.dart'; + +void main() { + late DebugPrintCallback debugPrintOriginal; + late List logs; + + setUp(() { + debugPrintOriginal = debugPrint; + logs = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + }); + + tearDown(() { + debugPrint = debugPrintOriginal; + }); + + group('parseListaTolerante', () { + test( + 'salta 1 entrada invalida entre 3 validas, retorna las 3 sobrevivientes y loguea el salto', + () { + final List datos = [ + {'valor': 1}, + {'valor': 2}, + 'entrada-invalida', // not a Map -> must be skipped, not thrown + {'valor': 3}, + ]; + + final resultado = parseListaTolerante( + datos, + (entrada) => entrada['valor'] as int, + subsistema: 'prueba', + coleccion: 'items', + ); + + expect(resultado.validas, [1, 2, 3]); + expect(resultado.saltadas, 1); + expect( + logs.any((l) => l.startsWith('[PluriWave][persistencia]')), + isTrue, + reason: 'debe loguear el salto con el prefijo de diagnostico', + ); + }, + ); + + test( + 'entrada valida cuyo parser lanza tambien se salta y se loguea (no solo tipos incorrectos)', + () { + final List datos = [ + {'valor': 1}, + {'otraClave': 'sin valor'}, // Map valido, pero el parser fallara + ]; + + final resultado = parseListaTolerante( + datos, + (entrada) => entrada['valor'] as int, + subsistema: 'prueba', + coleccion: 'items', + ); + + expect(resultado.validas, [1]); + expect(resultado.saltadas, 1); + }, + ); + + test('datos null retorna sobrevivientes vacios sin loguear', () { + final resultado = parseListaTolerante( + null, + (entrada) => entrada['valor'] as int, + subsistema: 'prueba', + coleccion: 'items', + ); + + expect(resultado.validas, isEmpty); + expect(resultado.saltadas, 0); + expect(logs, isEmpty); + }); + }); + + group('parseMapaTolerante', () { + test( + 'salta 1 VALOR invalido entre 3 entradas, preserva las claves originales', + () { + final Map datos = { + 'k1': {'valor': 10}, + 'k2': 'valor-no-parseable', + 'k3': {'valor': 30}, + 'k4': {'valor': 40}, + }; + + final resultado = parseMapaTolerante( + datos, + (valor) => (valor as Map)['valor'] as int, + subsistema: 'prueba', + coleccion: 'mapa', + ); + + expect(resultado.validas, {'k1': 10, 'k3': 30, 'k4': 40}); + expect(resultado.saltadas, 1); + expect( + resultado.validas.keys, + containsAll(['k1', 'k3', 'k4']), + reason: 'las claves originales de las entradas sobrevivientes no deben alterarse', + ); + }, + ); + }); + + group('degradado total', () { + test( + 'ambos helpers en entrada 100% invalida retornan sobrevivientes vacios sin lanzar excepcion, y loguean', + () { + final List datosLista = ['x', 'y', 'z']; + final resultadoLista = parseListaTolerante( + datosLista, + (entrada) => entrada['valor'] as int, + subsistema: 'prueba', + coleccion: 'items', + ); + expect(resultadoLista.validas, isEmpty); + expect(resultadoLista.saltadas, 3); + expect(logs, isNotEmpty); + + logs.clear(); + final Map datosMapa = {'k1': 'x', 'k2': 'y'}; + final resultadoMapa = parseMapaTolerante( + datosMapa, + (valor) => (valor as Map)['valor'] as int, + subsistema: 'prueba', + coleccion: 'mapa', + ); + expect(resultadoMapa.validas, isEmpty); + expect(resultadoMapa.saltadas, 2); + expect(logs, isNotEmpty); + }, + ); + }); +} diff --git a/test/servicios/servicio_alarmas_corrupcion_test.dart b/test/servicios/servicio_alarmas_corrupcion_test.dart new file mode 100644 index 0000000..58fa820 --- /dev/null +++ b/test/servicios/servicio_alarmas_corrupcion_test.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _claveAlarmas = 'alarmas_musicales_v1'; + +/// SharedPreferences spy: only the members ServicioAlarmas touches are +/// implemented; everything else throws via noSuchMethod. Mirrors +/// servicio_alarmas_cache_test.dart's _PrefsEspia, plus initial-value +/// seeding so corruption scenarios can be set up directly. +class _PrefsEspia implements SharedPreferences { + _PrefsEspia({Map? inicial}) : _datos = {...?inicial}; + + final Map _datos; + int escriturasString = 0; + int lecturasString = 0; + + @override + String? getString(String key) { + lecturasString++; + return _datos[key] as String?; + } + + @override + Future setString(String key, String value) async { + escriturasString++; + _datos[key] = value; + return true; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Map _alarmaValida(String id, int hora) => { + 'id': id, + 'nombre': 'Alarma $id', + 'hora': hora, + 'minuto': 0, + 'tipoProgramacion': 'diaria', + 'diasSemana': [], +}; + +void main() { + group( + 'ServicioAlarmas — lectura tolerante y guardia de degradacion ' + '(persistence-resilience)', + () { + test( + 'carga parcial (2 validas + 1 con id de tipo incorrecto): ' + 'recalcularTodas no reescribe en cada tick (D3, no-thrash)', + () async { + final raw = jsonEncode({ + 'alarmas': [ + _alarmaValida('a1', 7), + _alarmaValida('a2', 8), + // id con tipo incorrecto -> AlarmaMusical.fromJson lanza al + // hacer `json['id'] as String`; debe saltarse, no tumbar todo. + {..._alarmaValida('a3', 9), 'id': 42}, + ], + 'vacaciones': [], + 'excepciones': [], + }); + final prefs = _PrefsEspia(inicial: {_claveAlarmas: raw}); + final ahora = DateTime(2026, 6, 11, 6, 0); + final servicio = ServicioAlarmas(prefs: prefs, reloj: () => ahora); + + final config = await servicio.cargar(); + expect(config.alarmas, hasLength(2)); + expect(config.alarmas.map((a) => a.id).toSet(), {'a1', 'a2'}); + + final escriturasBase = prefs.escriturasString; + await servicio.recalcularTodas(); + await servicio.recalcularTodas(); + + expect( + prefs.escriturasString - escriturasBase, + lessThanOrEqualTo(1), + reason: + 'la cache normalizada evita que el dirty-guard se dispare ' + 'en cada tick de 60s (D3)', + ); + }, + ); + + test( + 'carga totalmente corrupta: cargar() queda vacio, recalcularTodas ' + 'no escribe, el raw original persiste intacto (D4)', + () async { + const raw = '{bad'; + final prefs = _PrefsEspia(inicial: {_claveAlarmas: raw}); + final servicio = ServicioAlarmas( + prefs: prefs, + reloj: () => DateTime(2026, 6, 11, 6, 0), + ); + + final config = await servicio.cargar(); + expect(config.alarmas, isEmpty); + + final escriturasBase = prefs.escriturasString; + await servicio.recalcularTodas(); + + expect(prefs.escriturasString, escriturasBase); + expect( + prefs.getString(_claveAlarmas), + raw, + reason: + 'el raw corrupto original debe preservarse intacto en ' + 'disco mientras la lectura este degradada', + ); + }, + ); + + test( + 'guardarAlarma tras una lectura degradada escribe una vez y ' + 'restaura la autoridad de escritura (D4)', + () async { + const raw = '{bad'; + final prefs = _PrefsEspia(inicial: {_claveAlarmas: raw}); + var ahora = DateTime(2026, 6, 11, 6, 0); + final servicio = ServicioAlarmas(prefs: prefs, reloj: () => ahora); + await servicio.cargar(); + + final escriturasBase = prefs.escriturasString; + final nueva = servicio.crearAlarma( + nombre: 'Nueva', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + await servicio.guardarAlarma(nueva); + + expect(prefs.escriturasString, escriturasBase + 1); + expect(prefs.getString(_claveAlarmas), isNot(raw)); + + // Un cambio real de agenda despues del guardado explicito debe + // volver a escribir con normalidad -- la bandera de degradacion + // no puede quedar pegada para siempre. + final escriturasTrasGuardar = prefs.escriturasString; + ahora = DateTime(2026, 6, 12, 8, 0); + await servicio.recalcularTodas(); + + expect(prefs.escriturasString, escriturasTrasGuardar + 1); + }, + ); + + test( + 'entrada sin id se omite y jamas se le fabrica uno nuevo (D2)', + () async { + final raw = jsonEncode({ + 'alarmas': [ + { + 'nombre': 'Sin id', + 'hora': 6, + 'minuto': 0, + 'tipoProgramacion': 'diaria', + 'diasSemana': [], + }, + _alarmaValida('a1', 7), + _alarmaValida('a2', 8), + ], + 'vacaciones': [], + 'excepciones': [], + }); + final prefs = _PrefsEspia(inicial: {_claveAlarmas: raw}); + final servicio = ServicioAlarmas( + prefs: prefs, + reloj: () => DateTime(2026, 6, 11, 6, 0), + ); + + final config = await servicio.cargar(); + + expect(config.alarmas, hasLength(2)); + expect(config.alarmas.map((a) => a.id).toSet(), {'a1', 'a2'}); + }, + ); + }, + ); +}