fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s

A single malformed alarm entry (bad/missing id, wrong type) used to
discard the ENTIRE persisted list on next load, and a fully unparseable
payload let the periodic recalculation silently overwrite it with an
empty one -- both destroyed valid alarms with no user action.

Adds a shared per-entry tolerant-parse helper (persistencia_tolerante.dart)
that skips and logs only the bad entry; ServicioAlarmas now normalizes its
cached raw after a partial load (no dirty-guard thrash) and sets a
degraded-read flag after a total decode failure that suppresses automatic
writes until a good read or an explicit user mutation restores authority.
This commit is contained in:
2026-07-11 12:27:39 +02:00
parent 23ab3494a7
commit 65c1ac2085
5 changed files with 514 additions and 24 deletions
@@ -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<String> 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<dynamic> datos = [
{'valor': 1},
{'valor': 2},
'entrada-invalida', // not a Map -> must be skipped, not thrown
{'valor': 3},
];
final resultado = parseListaTolerante<int>(
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<dynamic> datos = [
{'valor': 1},
{'otraClave': 'sin valor'}, // Map valido, pero el parser fallara
];
final resultado = parseListaTolerante<int>(
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<int>(
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<String, dynamic> datos = {
'k1': {'valor': 10},
'k2': 'valor-no-parseable',
'k3': {'valor': 30},
'k4': {'valor': 40},
};
final resultado = parseMapaTolerante<int>(
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<dynamic> datosLista = ['x', 'y', 'z'];
final resultadoLista = parseListaTolerante<int>(
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<String, dynamic> datosMapa = {'k1': 'x', 'k2': 'y'};
final resultadoMapa = parseMapaTolerante<int>(
datosMapa,
(valor) => (valor as Map)['valor'] as int,
subsistema: 'prueba',
coleccion: 'mapa',
);
expect(resultadoMapa.validas, isEmpty);
expect(resultadoMapa.saltadas, 2);
expect(logs, isNotEmpty);
},
);
});
}
@@ -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<String, Object>? inicial}) : _datos = {...?inicial};
final Map<String, Object> _datos;
int escriturasString = 0;
int lecturasString = 0;
@override
String? getString(String key) {
lecturasString++;
return _datos[key] as String?;
}
@override
Future<bool> setString(String key, String value) async {
escriturasString++;
_datos[key] = value;
return true;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Map<String, dynamic> _alarmaValida(String id, int hora) => {
'id': id,
'nombre': 'Alarma $id',
'hora': hora,
'minuto': 0,
'tipoProgramacion': 'diaria',
'diasSemana': <int>[],
};
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': <int>[],
},
_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'});
},
);
},
);
}