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.
184 lines
5.9 KiB
Dart
184 lines
5.9 KiB
Dart
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'});
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|