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.
This commit is contained in:
@@ -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<void> _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<void> _limpiarFalloProgramacion(String alarmaId) async {
|
||||
try {
|
||||
final config = await servicio.limpiarFalloProgramacion(alarmaId);
|
||||
_aplicar(config);
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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<String> 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<void> programar(AlarmaMusical alarma) async {
|
||||
if (fallaProgramar) {
|
||||
if (fallaProgramar || idsFallanProgramar.contains(alarma.id)) {
|
||||
throw StateError('fake programar failure');
|
||||
}
|
||||
programadas.add(alarma);
|
||||
|
||||
Reference in New Issue
Block a user