From 47d0b8a053b2dbefbe776f1fce8ad33ea43d07b1 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 21:00:51 +0200 Subject: [PATCH] feat(alarmas): record and clear per-alarm scheduling failures Adds ServicioAlarmas.registrarFalloProgramacion/limpiarFalloProgramacion, persisting a scheduling-reliability failure through the same ExcepcionAlarma model saltarProxima already uses. Only one failure record is kept per alarm (latest attempt wins) and skipNext entries for any alarm are never touched. EstadoAlarmas wiring follows next. --- lib/servicios/servicio_alarmas.dart | 56 +++++++ ...vicio_alarmas_fallo_programacion_test.dart | 150 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 test/servicios/servicio_alarmas_fallo_programacion_test.dart diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index adadf0b..97c7cc9 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -350,6 +350,62 @@ class ServicioAlarmas { return nuevo; }); + /// Records a scheduling-reliability failure for [alarmaId] (fix/alarmas- + /// fallos-silenciosos): reuses the SAME `ExcepcionAlarma` model + /// `saltarProxima` already persists, so `EstadoAlarmas.ultimaExcepcionPara` + /// surfaces it on the exact alarm card affected instead of only a + /// transient, alarm-agnostic message. A previous FAILURE record for the + /// same alarm is replaced (only the latest attempt's outcome matters) -- + /// any `skipNext` exception for this or other alarms is left untouched. + /// Never affects scheduling: `ServicioProgramacionAlarmas._esValida` only + /// treats `tipoSaltoSiguiente` as an actual skip. + Future registrarFalloProgramacion( + String alarmaId, + DateTime ejecucion, + String tipo, + ) => _enCola(() async { + final config = await _configActual(); + final excepciones = [ + ..._sinFalloPrevio(config.excepciones, alarmaId), + ExcepcionAlarma(alarmaId: alarmaId, ejecucion: ejecucion, tipo: tipo), + ]; + final nuevo = ConfiguracionAlarmas( + alarmas: config.alarmas, + vacaciones: config.vacaciones, + excepciones: excepciones, + ); + await _guardar(nuevo); + return nuevo; + }); + + /// Clears any outstanding failure record for [alarmaId] (a subsequent + /// scheduling attempt succeeded). No-op when there is nothing to clear. + Future limpiarFalloProgramacion( + String alarmaId, + ) => _enCola(() async { + final config = await _configActual(); + final sinFallo = _sinFalloPrevio(config.excepciones, alarmaId); + if (sinFallo.length == config.excepciones.length) return config; + final nuevo = ConfiguracionAlarmas( + alarmas: config.alarmas, + vacaciones: config.vacaciones, + excepciones: sinFallo, + ); + await _guardar(nuevo); + return nuevo; + }); + + List _sinFalloPrevio( + List excepciones, + String alarmaId, + ) => excepciones + .where( + (e) => + !(e.alarmaId == alarmaId && + ExcepcionAlarma.tiposFallo.contains(e.tipo)), + ) + .toList(); + Future posponerEjecucion( String alarmaId, DateTime ejecucion, diff --git a/test/servicios/servicio_alarmas_fallo_programacion_test.dart b/test/servicios/servicio_alarmas_fallo_programacion_test.dart new file mode 100644 index 0000000..72b0370 --- /dev/null +++ b/test/servicios/servicio_alarmas_fallo_programacion_test.dart @@ -0,0 +1,150 @@ +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'; + +/// Coverage for the scheduling-reliability failure records (fix/alarmas- +/// fallos-silenciosos): a failed `android.programar` call is no longer only +/// a transient, alarm-agnostic `EstadoAlarmas.error` string -- it is also +/// recorded per-alarm through the SAME `ExcepcionAlarma` model `saltarProxima` +/// already uses, so `EstadoAlarmas.ultimaExcepcionPara` can surface it on the +/// exact card affected. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('registrarFalloProgramacion agrega una excepcion de fallo para la ' + 'alarma indicada', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final config = await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(1)); + expect(config.excepciones.single.alarmaId, 'a1'); + expect( + config.excepciones.single.tipo, + ExcepcionAlarma.tipoFalloProgramacion, + ); + }); + + test('registrarFalloProgramacion reemplaza un fallo previo de la MISMA ' + 'alarma en vez de acumular', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloPreaviso, + ); + final config = await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 26, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(1)); + expect( + config.excepciones.single.tipo, + ExcepcionAlarma.tipoFalloProgramacion, + ); + }); + + test('registrarFalloProgramacion NUNCA toca las excepciones skipNext de ' + 'otras alarmas ni de la misma', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.saltarProxima('a1'); + + final config = await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 26, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(2)); + expect( + config.excepciones.map((e) => e.tipo), + containsAll([ + ExcepcionAlarma.tipoSaltoSiguiente, + ExcepcionAlarma.tipoFalloProgramacion, + ]), + ); + }); + + test('limpiarFalloProgramacion elimina el fallo registrado para esa ' + 'alarma', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + final config = await servicio.limpiarFalloProgramacion('a1'); + + expect(config.excepciones, isEmpty); + }); + + test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste ' + 'cambios', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final config = await servicio.limpiarFalloProgramacion('a1'); + + expect(config.excepciones, isEmpty); + }); +}