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:
@@ -142,6 +142,14 @@ class AlarmaMusical {
|
|||||||
'actualizadaEn': actualizadaEn?.toIso8601String(),
|
'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<String, dynamic> json) {
|
factory AlarmaMusical.fromJson(Map<String, dynamic> json) {
|
||||||
return AlarmaMusical(
|
return AlarmaMusical(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
|
|||||||
@@ -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<T>` for
|
||||||
|
/// [parseListaTolerante], a `Map<String, V>` 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> = ({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<String, dynamic>` -> `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<List<T>> parseListaTolerante<T>(
|
||||||
|
List<dynamic>? datos,
|
||||||
|
T Function(Map<String, dynamic> entrada) parser, {
|
||||||
|
required String subsistema,
|
||||||
|
required String coleccion,
|
||||||
|
}) {
|
||||||
|
if (datos == null || datos.isEmpty) {
|
||||||
|
return (validas: <T>[], saltadas: 0);
|
||||||
|
}
|
||||||
|
final validas = <T>[];
|
||||||
|
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<String, dynamic>.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<String, dynamic>?`). This helper never calls
|
||||||
|
/// `jsonDecode` itself — see [parseListaTolerante] for the same rationale.
|
||||||
|
ResultadoTolerante<Map<String, V>> parseMapaTolerante<V>(
|
||||||
|
Map<String, dynamic>? datos,
|
||||||
|
V Function(dynamic valor) parser, {
|
||||||
|
required String subsistema,
|
||||||
|
required String coleccion,
|
||||||
|
}) {
|
||||||
|
if (datos == null || datos.isEmpty) {
|
||||||
|
return (validas: <String, V>{}, saltadas: 0);
|
||||||
|
}
|
||||||
|
final validas = <String, V>{};
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
|
|
||||||
import '../modelos/alarma_musical.dart';
|
import '../modelos/alarma_musical.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
|
import 'persistencia_tolerante.dart';
|
||||||
import 'servicio_programacion_alarmas.dart';
|
import 'servicio_programacion_alarmas.dart';
|
||||||
|
|
||||||
class ConfiguracionAlarmas {
|
class ConfiguracionAlarmas {
|
||||||
@@ -41,6 +42,14 @@ class ServicioAlarmas {
|
|||||||
String? _cacheRaw;
|
String? _cacheRaw;
|
||||||
Future<void> _cola = Future<void>.value();
|
Future<void> _cola = Future<void>.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<T> _enCola<T>(Future<T> Function() accion) {
|
Future<T> _enCola<T>(Future<T> Function() accion) {
|
||||||
final resultado = _cola.then((_) => accion());
|
final resultado = _cola.then((_) => accion());
|
||||||
_cola = resultado.then((_) {}, onError: (_) {});
|
_cola = resultado.then((_) {}, onError: (_) {});
|
||||||
@@ -63,12 +72,29 @@ class ServicioAlarmas {
|
|||||||
final raw = prefs.getString(_keyConfig);
|
final raw = prefs.getString(_keyConfig);
|
||||||
final config = _parsear(raw);
|
final config = _parsear(raw);
|
||||||
_cache = config;
|
_cache = config;
|
||||||
_cacheRaw = raw;
|
|
||||||
return config;
|
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) {
|
ConfiguracionAlarmas _parsear(String? raw) {
|
||||||
if (raw == null || raw.trim().isEmpty) {
|
if (raw == null || raw.trim().isEmpty) {
|
||||||
|
_lecturaAlarmasDegradada = false;
|
||||||
|
_cacheRaw = raw;
|
||||||
return const ConfiguracionAlarmas(
|
return const ConfiguracionAlarmas(
|
||||||
alarmas: [],
|
alarmas: [],
|
||||||
vacaciones: [],
|
vacaciones: [],
|
||||||
@@ -77,30 +103,44 @@ class ServicioAlarmas {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
return ConfiguracionAlarmas(
|
final alarmas = parseListaTolerante<AlarmaMusical>(
|
||||||
alarmas:
|
data['alarmas'] as List?,
|
||||||
(data['alarmas'] as List? ?? const [])
|
AlarmaMusical.fromJson,
|
||||||
.whereType<Map>()
|
subsistema: 'alarmas',
|
||||||
.map(
|
coleccion: 'alarmas',
|
||||||
(e) => AlarmaMusical.fromJson(Map<String, dynamic>.from(e)),
|
);
|
||||||
)
|
final vacaciones = parseListaTolerante<RangoVacaciones>(
|
||||||
.toList(),
|
data['vacaciones'] as List?,
|
||||||
vacaciones:
|
RangoVacaciones.fromJson,
|
||||||
(data['vacaciones'] as List? ?? const [])
|
subsistema: 'alarmas',
|
||||||
.whereType<Map>()
|
coleccion: 'vacaciones',
|
||||||
.map(
|
);
|
||||||
(e) => RangoVacaciones.fromJson(Map<String, dynamic>.from(e)),
|
final excepciones = parseListaTolerante<ExcepcionAlarma>(
|
||||||
)
|
data['excepciones'] as List?,
|
||||||
.toList(),
|
ExcepcionAlarma.fromJson,
|
||||||
excepciones:
|
subsistema: 'alarmas',
|
||||||
(data['excepciones'] as List? ?? const [])
|
coleccion: 'excepciones',
|
||||||
.whereType<Map>()
|
);
|
||||||
.map(
|
final config = ConfiguracionAlarmas(
|
||||||
(e) => ExcepcionAlarma.fromJson(Map<String, dynamic>.from(e)),
|
alarmas: alarmas.validas,
|
||||||
)
|
vacaciones: vacaciones.validas,
|
||||||
.toList(),
|
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(
|
return const ConfiguracionAlarmas(
|
||||||
alarmas: [],
|
alarmas: [],
|
||||||
vacaciones: [],
|
vacaciones: [],
|
||||||
@@ -200,6 +240,11 @@ class ServicioAlarmas {
|
|||||||
|
|
||||||
Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async {
|
Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async {
|
||||||
final config = await _configActual();
|
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(
|
final alarmas = _recalcularLista(
|
||||||
config.alarmas,
|
config.alarmas,
|
||||||
config.vacaciones,
|
config.vacaciones,
|
||||||
@@ -427,6 +472,10 @@ class ServicioAlarmas {
|
|||||||
await prefs.setString(_keyConfig, serializado);
|
await prefs.setString(_keyConfig, serializado);
|
||||||
_cache = config;
|
_cache = config;
|
||||||
_cacheRaw = serializado;
|
_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({
|
String _serializar(ConfiguracionAlarmas config) => jsonEncode({
|
||||||
|
|||||||
@@ -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