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:
2026-07-31 21:08:21 +02:00
parent 47d0b8a053
commit fd1b91fe9e
3 changed files with 208 additions and 2 deletions
+139
View File
@@ -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 '
+7 -1
View File
@@ -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);