From 88bd251ebaef60e22b471bfc2565f5c5a9e3ee2e Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 20:59:02 +0200 Subject: [PATCH] fix(alarmas): scope schedule-skip exceptions to skipNext only ExcepcionAlarma._esValida matched ANY exception tipo against an occurrence, treating it as a user skip. Only the 'skipNext' tipo existed until now, but the next commits reuse the same model to record scheduling-reliability failures per alarm (so the alarms list can surface them via ultimaExcepcionPara) -- without this guard, a recorded failure would be silently treated as if the user asked to skip that occurrence, corrupting scheduling. Adds tipo constants to ExcepcionAlarma for the upcoming failure kinds. --- lib/modelos/alarma_musical.dart | 37 +++++++++++++++++++ .../servicio_programacion_alarmas.dart | 6 +++ .../servicio_programacion_alarmas_test.dart | 36 ++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart index f57cff4..9e5983f 100644 --- a/lib/modelos/alarma_musical.dart +++ b/lib/modelos/alarma_musical.dart @@ -302,6 +302,43 @@ class ExcepcionAlarma { final DateTime ejecucion; final String tipo; + /// User-requested skip of the next occurrence (the only [tipo] this model + /// originally supported). `ServicioProgramacionAlarmas._esValida` only + /// treats THIS tipo as an actual schedule skip -- every tipo below records + /// a scheduling-reliability failure and must never affect which occurrence + /// fires next. + static const tipoSaltoSiguiente = 'skipNext'; + + /// The main alarm registration with the OS failed (`android.programar` + /// threw). Recorded per-alarm so the alarms list can mark the exact card + /// affected instead of only a transient, alarm-agnostic app-wide message. + static const tipoFalloProgramacion = 'schedulingFailed'; + + /// The main alarm registered successfully but its 30-minute pre-notice + /// reminder did not (native `SecurityException` scheduling the pre-notice + /// alone) -- distinguished from [tipoFalloProgramacion] because the alarm + /// itself will still ring; only the early warning is missing. + static const tipoFalloPreaviso = 'preNoticeFailed'; + + /// The OS refused to start the foreground ringing service when the alarm + /// fired (e.g. a background-restricted app), so the alarm never actually + /// rang even though it was armed. + static const tipoFalloServicioSonido = 'foregroundServiceFailed'; + + /// A per-alarm reschedule after boot/unlock failed while sibling alarms + /// succeeded, leaving this one specific alarm unscheduled. + static const tipoFalloReprogramacionArranque = 'rescheduleAfterBootFailed'; + + /// Every tipo above that represents a reliability FAILURE rather than a + /// deliberate user action -- used by the UI to decide whether to mark a + /// card, and by [ServicioAlarmas] to know which prior record to replace. + static const tiposFallo = { + tipoFalloProgramacion, + tipoFalloPreaviso, + tipoFalloServicioSonido, + tipoFalloReprogramacionArranque, + }; + Map toJson() => { 'alarmaId': alarmaId, 'ejecucion': ejecucion.toIso8601String(), diff --git a/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart index fc01b8a..9b7f7cc 100644 --- a/lib/servicios/servicio_programacion_alarmas.dart +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -150,9 +150,15 @@ class ServicioProgramacionAlarmas { if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) { return false; } + // Only a deliberate user skip ever removes a candidate occurrence. + // Reliability-failure records share this same list/model (so the alarms + // list can surface them per-alarm via `ultimaExcepcionPara`), but they + // must never be mistaken for a skip — that would silently jump the + // alarm to its NEXT occurrence instead of just flagging the failed one. return !excepciones.any( (excepcion) => excepcion.alarmaId == alarma.id && + excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente && _mismaEjecucion(excepcion.ejecucion, candidato), ); } diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart index eb96415..015e4c8 100644 --- a/test/servicios/servicio_programacion_alarmas_test.dart +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -75,6 +75,42 @@ void main() { expect(proxima, DateTime(2026, 5, 23, 9)); }); + test( + 'un registro de fallo de programacion NO omite esa ejecucion (solo ' + 'skipNext debe hacerlo)', + () { + final alarma = AlarmaMusical( + id: 'a4', + nombre: 'Diaria', + hora: 9, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + final ocurrencia = DateTime(2026, 5, 22, 9); + + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 5, 22, 8), + excepciones: [ + ExcepcionAlarma( + alarmaId: 'a4', + ejecucion: ocurrencia, + tipo: ExcepcionAlarma.tipoFalloProgramacion, + ), + ], + ); + + expect( + proxima, + ocurrencia, + reason: + 'un fallo de programacion registrado no debe comportarse ' + 'como un salto de usuario', + ); + }, + ); + test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () { expect( servicio.calcularSnooze(DateTime(2026, 5, 21, 7), 10),