fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms
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:
@@ -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'});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user