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);