diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 8bcfc5d..18a3a7d 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -568,13 +568,34 @@ class ServicioAlarmas { alarma.activa && alarma.snoozeHasta != null && alarma.snoozeHasta!.isAfter(ahora); + // Self-heal for state poisoned before the Detener anchor fix: a stop + // that closed a FUTURE occurrence wrote it into + // ultimaEjecucionGestionada, and _esValida rejects any candidate + // matching it -- so the alarm silently skips that day forever after, + // with nothing in the UI to explain it. An occurrence cannot have been + // handled before it happens, so a value meaningfully in the future is + // corrupt by definition and safe to drop: it can only ever suppress a + // real future ring, never prevent a double-fire (which needs a PAST + // occurrence to guard). Placed here, in the recalculation every load and + // every mutation already funnels through, so an affected alarm heals on + // the next app open with no user action. + final gestionada = alarma.ultimaEjecucionGestionada; + final gestionadaCorrupta = + gestionada != null && + gestionada.isAfter( + ahora.add(ServicioProgramacionAlarmas.toleranciaDisparoInminente), + ); + final saneada = + gestionadaCorrupta + ? alarma.copyWith(limpiarUltimaEjecucionGestionada: true) + : alarma; final proxima = _programacion.calcularProxima( - alarma: alarma, + alarma: saneada, desde: ahora, vacaciones: vacaciones, excepciones: excepciones, ); - return alarma.copyWith( + return saneada.copyWith( proximaEjecucion: proxima, limpiarProximaEjecucion: true, limpiarSnooze: !snoozeActivo, diff --git a/test/estado/estado_alarmas_detener_ancla_test.dart b/test/estado/estado_alarmas_detener_ancla_test.dart index 618cf34..e31a814 100644 --- a/test/estado/estado_alarmas_detener_ancla_test.dart +++ b/test/estado/estado_alarmas_detener_ancla_test.dart @@ -96,6 +96,53 @@ void main() { ); }); + test('estado ya envenenado se cura solo: una ocurrencia futura marcada ' + 'como gestionada se descarta al recalcular', () async { + // Devices that ran the buggy build carry the poisoned value in + // SharedPreferences. Without this, the fix would still leave the + // affected alarm skipping one more time, with nothing in the UI to + // explain it -- and the user would reasonably read that as "not fixed". + final ahora = DateTime(2026, 8, 3, 9, 0); + final servicio = ServicioAlarmas(reloj: () => ahora); + + // Saved by the buggy stop path: next Monday recorded as already handled. + await servicio.guardarAlarma( + semanalLunes( + 'a3', + ).copyWith(ultimaEjecucionGestionada: DateTime(2026, 8, 10, 16, 20)), + ); + + final config = await servicio.recalcularTodas(); + final alarma = config.alarmas.single; + + expect(alarma.ultimaEjecucionGestionada, isNull); + expect( + alarma.proximaEjecucion, + DateTime(2026, 8, 3, 16, 20), + reason: 'y con el dato corrupto fuera, hoy vuelve a ser candidata', + ); + }); + + test('una ocurrencia gestionada REAL (pasada) se conserva: es la que evita ' + 'que la alarma vuelva a sonar en el mismo minuto', () async { + final ahora = DateTime(2026, 8, 3, 16, 20, 30); + final servicio = ServicioAlarmas(reloj: () => ahora); + final gestionada = DateTime(2026, 8, 3, 16, 20); + + await servicio.guardarAlarma( + semanalLunes('a4').copyWith(ultimaEjecucionGestionada: gestionada), + ); + + final alarma = (await servicio.recalcularTodas()).alarmas.single; + + expect(alarma.ultimaEjecucionGestionada, gestionada); + expect( + alarma.proximaEjecucion, + DateTime(2026, 8, 10, 16, 20), + reason: 'la de hoy ya sonó, la siguiente es el lunes que viene', + ); + }); + test( 'Detener sin nada sonando tampoco consume la próxima ocurrencia', () async {