fix(alarmas): stop Detener from consuming an occurrence that never rang
Reported: an alarm set for Monday 16:20 never rang, and the "next alarm"
banner showed a different alarm (the next morning's) instead. No
vacation range involved, both alarms active.
finalizarEjecucion anchored the completed occurrence to proximaEjecucion
with no check that it was the one actually ringing. On the native-fire
path the fire-time sync advances proximaEjecucion to the NEXT occurrence
before the user can reach the ring screen, so tapping Detener recorded a
FUTURE occurrence in ultimaEjecucionGestionada.
ServicioProgramacionAlarmas._esValida then rejects that occurrence for
real: a Monday-only alarm stopped today simply never rings next Monday,
and every sibling outranks it in the banner because its own
proximaEjecucion is a week out.
Reproduced at its purest in the second test: with nothing ringing at
09:01 on Monday, Detener pushed a 16:20 alarm to the FOLLOWING Monday.
posponerAlarma already had exactly this guard -- 9c7cf4e, "anchor snooze
to the ringing occurrence, never a future one", written after the same
failure showed up as a snooze armed a day out. It was applied to the
snooze path and never to the stop path, which sat ten lines below it in
the same file with the identical hazard.
Both paths now share one _ocurrenciaSonando helper so they cannot drift
apart again, and the reason lives in its doc comment rather than in a
comment on one of the two callers.
Also drops snoozeHasta from the stop path's candidate chain: a pending
snooze target is in the future by definition, and snoozeOrigen already
covers a ring that follows a snooze.
Tests: 1120 -> 1122.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Reported on-device: an alarm set for Monday 16:20 never rang, and the
|
||||
/// "next alarm" banner showed a DIFFERENT alarm (the next morning's) instead.
|
||||
///
|
||||
/// Root cause: `finalizarEjecucion` ("Detener") anchored the completed
|
||||
/// occurrence to `proximaEjecucion` with no check that it was the one
|
||||
/// actually ringing. On the native-fire path the fire-time sync advances
|
||||
/// `proximaEjecucion` to the NEXT occurrence BEFORE the user can reach the
|
||||
/// ring screen — so stopping today's ring recorded NEXT week's occurrence as
|
||||
/// already handled. `ServicioProgramacionAlarmas._esValida` then rejected
|
||||
/// that occurrence for real, and the alarm silently jumped past it: it never
|
||||
/// rang, and every sibling alarm outranked it in the banner.
|
||||
///
|
||||
/// This is the exact hazard `posponerAlarma` was fixed for in `9c7cf4e`
|
||||
/// ("anchor snooze to the ringing occurrence, never a future one"). The guard
|
||||
/// landed on the snooze path and never on the stop path, which sits directly
|
||||
/// below it in the same file.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
AlarmaMusical semanalLunes(String id) => AlarmaMusical(
|
||||
id: id,
|
||||
nombre: 'Tarde del lunes',
|
||||
hora: 16,
|
||||
minuto: 20,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
|
||||
diasSemana: const [DateTime.monday],
|
||||
);
|
||||
|
||||
test('Detener cierra la ocurrencia que sonaba, no quema la siguiente '
|
||||
'(el nativo ya avanzó proximaEjecucion antes de que el usuario '
|
||||
'llegue a la pantalla)', () async {
|
||||
// Monday 2026-08-03.
|
||||
var ahora = DateTime(2026, 8, 3, 16, 0);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.guardarAlarma(semanalLunes('a1'));
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 3, 16, 20),
|
||||
);
|
||||
|
||||
// 16:20 — it rings. The native scheduler records the occurrence handled
|
||||
// and rearms; the cold-start sync brings that over, which advances
|
||||
// proximaEjecucion to NEXT Monday while the alarm is still ringing.
|
||||
ahora = DateTime(2026, 8, 3, 16, 20, 5);
|
||||
android.ejecucionesNativas.add(
|
||||
EjecucionAlarmaNativa(
|
||||
alarmaId: 'a1',
|
||||
gestionadaEn: DateTime(2026, 8, 3, 16, 20),
|
||||
),
|
||||
);
|
||||
await estado.inicializar();
|
||||
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 10, 16, 20),
|
||||
reason: 'precondición: el nativo ya avanzó a la semana siguiente',
|
||||
);
|
||||
|
||||
// NOW the user taps "Detener" on the ring screen.
|
||||
await estado.finalizarEjecucion('a1');
|
||||
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 10, 16, 20),
|
||||
reason:
|
||||
'Detener debe cerrar la ocurrencia que sonaba (hoy), no consumir '
|
||||
'la del lunes que viene empujándola a 2026-08-17',
|
||||
);
|
||||
expect(
|
||||
estado.alarmas.single.ultimaEjecucionGestionada,
|
||||
isNot(DateTime(2026, 8, 10, 16, 20)),
|
||||
reason:
|
||||
'marcar como gestionada una ocurrencia futura es justo lo que hace '
|
||||
'que _esValida la rechace y esa alarma no suene ese día',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Detener sin nada sonando tampoco consume la próxima ocurrencia',
|
||||
() async {
|
||||
// Defensive: the ring screen is the only production caller, but a stale
|
||||
// route or a duplicated stop event must not silently eat a day.
|
||||
var ahora = DateTime(2026, 8, 3, 9, 0);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.guardarAlarma(semanalLunes('a2'));
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 3, 16, 20),
|
||||
);
|
||||
|
||||
ahora = DateTime(2026, 8, 3, 9, 1);
|
||||
await estado.finalizarEjecucion('a2');
|
||||
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 8, 3, 16, 20),
|
||||
reason: 'a las 09:01 la ocurrencia de las 16:20 no está sonando',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user