From fd1b91fe9eaa2eb8c366a60378a2461beefbe9f4 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 21:08:21 +0200 Subject: [PATCH] fix(alarmas): wire scheduling failures into per-alarm exceptions guardarAlarma/posponerAlarma/posponerProximaDesdePreaviso now record a scheduling failure via ServicioAlarmas.registrarFalloProgramacion on catch and clear it on a successful (re)schedule, in addition to the existing transient EstadoAlarmas.error string. This makes the failure visible per-alarm via ultimaExcepcionPara instead of only a generic app-wide message. Also fixes _sincronizarTodas: a single alarm's android.programar throw used to abort the whole loop, silently skipping every sibling alarm scheduled AFTER it on that pass (including on every app launch, via inicializar). Each alarm's outcome is now independent. --- lib/estado/estado_alarmas.dart | 63 +++++++++++- test/estado/estado_alarmas_test.dart | 139 +++++++++++++++++++++++++++ test/helpers/fakes_alarmas.dart | 8 +- 3 files changed, 208 insertions(+), 2 deletions(-) diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 8cdb7c3..6b8a53c 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -117,8 +117,10 @@ class EstadoAlarmas extends ChangeNotifier { '[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}', ); await android.programar(guardada); + await _limpiarFalloProgramacion(guardada.id); } catch (e) { _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -198,6 +200,45 @@ class EstadoAlarmas extends ChangeNotifier { } } + /// Records a main-alarm scheduling failure per-alarm (fix/alarmas-fallos- + /// silenciosos): before this, a failed `android.programar` call only set + /// the transient, alarm-agnostic [_error] string — the alarms list had no + /// way to mark the SPECIFIC card affected, so a failed alarm rendered + /// exactly like a working one. Never rethrows: a failure recording its own + /// failure must not mask the ORIGINAL scheduling error already captured in + /// [_error]. + Future _registrarFalloProgramacion( + String alarmaId, { + String tipo = ExcepcionAlarma.tipoFalloProgramacion, + }) async { + try { + final alarma = _buscarAlarma(alarmaId); + final ejecucion = alarma?.proximaProgramable ?? servicio.ahora(); + final config = await servicio.registrarFalloProgramacion( + alarmaId, + ejecucion, + tipo, + ); + _aplicar(config); + } catch (e) { + debugPrint( + '[PluriWave][alarmas] registrar fallo programacion ERROR $e', + ); + } + } + + /// Clears a previously recorded scheduling failure once a later attempt + /// for the same alarm succeeds (D5-style recovery, mirroring how [_error] + /// itself already clears on a successful retry). + Future _limpiarFalloProgramacion(String alarmaId) async { + try { + final config = await servicio.limpiarFalloProgramacion(alarmaId); + _aplicar(config); + } catch (e) { + debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e'); + } + } + Future cambiarActiva(AlarmaMusical alarma, bool activa) async { await guardarAlarma(alarma.copyWith(activa: activa)); } @@ -267,10 +308,12 @@ class EstadoAlarmas extends ChangeNotifier { if (actualizada != null) { await _solicitarPermisosNecesariosParaAlarma(); await android.programar(actualizada); + await _limpiarFalloProgramacion(alarma.id); } } catch (e) { _error = 'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -298,10 +341,12 @@ class EstadoAlarmas extends ChangeNotifier { if (actualizada != null) { await _solicitarPermisosNecesariosParaAlarma(); await android.programar(actualizada); + await _limpiarFalloProgramacion(alarma.id); } } catch (e) { _error = 'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -622,8 +667,24 @@ class EstadoAlarmas extends ChangeNotifier { if (_alarmas.any((alarma) => alarma.activa)) { await _solicitarPermisosNecesariosParaAlarma(); } + // Per-alarm try/catch (fix/alarmas-fallos-silenciosos): before this, a + // SINGLE alarm's `programar` throw aborted the whole loop, so every + // sibling AFTER the failing one in `_alarmas` silently never reached + // `android.programar` on this pass -- on a fresh launch (`inicializar`) + // that meant some active alarms were never (re)armed with the OS at all, + // with nothing to show for it beyond a generic load error. Each alarm + // now gets its own outcome recorded, and one failure never blocks its + // siblings. for (final alarma in _alarmas) { - await android.programar(alarma); + try { + await android.programar(alarma); + await _limpiarFalloProgramacion(alarma.id); + } catch (e) { + debugPrint( + '[PluriWave][alarmas] sincronizar todas ERROR id=${alarma.id} $e', + ); + await _registrarFalloProgramacion(alarma.id); + } } } diff --git a/test/estado/estado_alarmas_test.dart b/test/estado/estado_alarmas_test.dart index b320f24..417c9c7 100644 --- a/test/estado/estado_alarmas_test.dart +++ b/test/estado/estado_alarmas_test.dart @@ -611,6 +611,145 @@ void main() { }, ); + test( + 'guardarAlarma: cuando android.programar falla, marca la alarma con una ' + 'excepcion de fallo visible via ultimaExcepcionPara', + () async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'fallo1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + final excepcion = estado.ultimaExcepcionPara('fallo1'); + expect(excepcion, isNotNull); + expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion); + expect(estado.error, isNotNull); + }, + ); + + test( + 'guardarAlarma: un reintento exitoso limpia la excepcion de fallo previa', + () async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + final alarma = const AlarmaMusical( + id: 'fallo2', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ); + await estado.guardarAlarma(alarma); + expect(estado.ultimaExcepcionPara('fallo2'), isNotNull); + + android.fallaProgramar = false; + await estado.guardarAlarma(estado.alarmas.single); + + expect(estado.ultimaExcepcionPara('fallo2'), isNull); + }, + ); + + test( + 'guardarAlarma en el camino feliz nunca registra una excepcion de fallo', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ok1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(estado.ultimaExcepcionPara('ok1'), isNull); + expect(estado.error, isNull); + }, + ); + + test( + 'inicializar: un fallo de programacion en UNA alarma no aborta la ' + 'sincronizacion de las demas (S-sincronizarTodas continua tras error)', + () async { + final android = FakePuertoAlarmasAndroid() + ..idsFallanProgramar.add('rota'); + final servicio = ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 6, 0), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'rota', + nombre: 'Rota', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'sana', + nombre: 'Sana', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final estado = EstadoAlarmas( + servicio: servicio, + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.inicializar(); + + expect( + android.programadas.map((a) => a.id), + contains('sana'), + reason: + 'la alarma sana debe seguir programandose aunque la rota falle', + ); + expect(estado.ultimaExcepcionPara('rota'), isNotNull); + expect(estado.ultimaExcepcionPara('sana'), isNull); + }, + ); + group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () { test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye ' '"ahora" (dias restantes derivables de finDia), o null si ninguno ' diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 0295138..e8b331e 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -41,6 +41,12 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { /// could not otherwise produce. bool fallaProgramar = false; + /// Test-only PER-ALARM failure switch (fix/alarmas-fallos-silenciosos): + /// [programar] throws only for ids in this set, letting a test simulate + /// one alarm failing to schedule while its siblings succeed -- the global + /// [fallaProgramar] switch cannot express that (it fails everything). + final Set idsFallanProgramar = {}; + /// Test-only failure switch: when true, [detenerSonidoActivo] reports an /// unconfirmed/failed stop instead of a confirmed one. bool fallaDetener = false; @@ -73,7 +79,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { @override Future programar(AlarmaMusical alarma) async { - if (fallaProgramar) { + if (fallaProgramar || idsFallanProgramar.contains(alarma.id)) { throw StateError('fake programar failure'); } programadas.add(alarma);