fix(alarm): anchor snooze to the ringing occurrence, never a future one
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s

posponerAlarma anchored the snooze to snoozeOrigen ?? proximaEjecucion,
but once the native fire path works, the fire-time sync records the
handled occurrence and recalculation advances proximaEjecucion to the
NEXT day before the user can even tap snooze on the still-ringing
screen. "Posponer 3" therefore armed the snooze a full day out
(captured on-device: snoozeCountdown remaining=1443 minutes). The bug
was invisible before because the broken delivery path never advanced
proximaEjecucion while ringing — each fix unmasked the next.

The anchor is now the newest occurrence that is not meaningfully in
the future (shared 90s imminence window): snoozeOrigen for re-snoozes,
proximaEjecucion on the watchdog path where it is still today's
just-due occurrence, ultimaEjecucionGestionada on the native-fire path
where the sync recorded the ringing occurrence, then now. ServicioAlarmas
exposes ahora() so the anchor uses the same injectable clock as the
rest of the scheduling math. Test fixtures that snoozed half an hour
before the ring — a state the ringing screen can never be in, since it
is posponerAlarma's only production caller — now move the clock to
ring time, preserving their original expectations.
This commit is contained in:
2026-07-12 23:17:26 +02:00
parent 597c98d0e7
commit 9c7cf4e261
4 changed files with 92 additions and 7 deletions
+21 -1
View File
@@ -7,6 +7,7 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
import '../servicios/servicio_programacion_alarmas.dart';
class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({
@@ -193,8 +194,27 @@ class EstadoAlarmas extends ChangeNotifier {
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
_error = null;
// The snooze anchors to the occurrence that is RINGING — never a future
// one. When the native fire works, the fire-time sync advances
// proximaEjecucion to the NEXT day before the user can even tap snooze,
// so anchoring to proximaEjecucion re-armed "posponer 3" a full day out
// (observed on-device: snooze armed for tomorrow 23:02). The ringing
// occurrence is the newest candidate not meaningfully in the future:
// snoozeOrigen (a re-snooze keeps the original anchor), then
// proximaEjecucion (watchdog path: still today's just-due occurrence),
// then ultimaEjecucionGestionada (native-fire path: the sync recorded
// the ringing occurrence there), then now.
final ahora = servicio.ahora();
final limite = ahora.add(
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
);
DateTime? sonando(DateTime? candidata) =>
candidata != null && !candidata.isAfter(limite) ? candidata : null;
final ejecucion =
alarma.snoozeOrigen ?? alarma.proximaEjecucion ?? DateTime.now();
sonando(alarma.snoozeOrigen) ??
sonando(alarma.proximaEjecucion) ??
sonando(alarma.ultimaEjecucionGestionada) ??
ahora;
debugPrint(
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
);
+5
View File
@@ -35,6 +35,11 @@ class ServicioAlarmas {
final DateTime Function() _reloj;
final _uuid = const Uuid();
/// Current time through the injected clock, so callers that need "now"
/// (e.g. the ring screen's snooze anchor) stay testable with the same
/// fixture clock the service itself computes with.
DateTime ahora() => _reloj();
// In-memory cache + single-writer queue (Design 3.5 / S3-R7): every
// mutation runs serialized through [_enCola] and reads [_cache], killing
// the read-modify-write race the old cargar()-before-each-mutation had.
+60 -5
View File
@@ -27,9 +27,13 @@ void main() {
snoozeMinutos: snoozeMinutos,
);
test('posponerAlarma ancla snoozeHasta en proximaEjecucion + minutos, '
test('posponerAlarma ancla el snooze a la ocurrencia sonando, '
'programa una sola vez y notifica', () async {
final ahora = DateTime(2026, 6, 11, 7, 0);
// The ring screen is posponerAlarma's ONLY production caller, so the
// anchor is the occurrence that is ringing (just due), never a future
// one. Saved at 7:00, ringing at 7:30:20 -> snooze lands at 7:35,
// computed from the 7:30 occurrence.
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
@@ -46,6 +50,7 @@ void main() {
var notificaciones = 0;
estado.addListener(() => notificaciones++);
ahora = DateTime(2026, 6, 11, 7, 30, 20);
await estado.posponerAlarma(alarma, 5);
expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 6, 11, 7, 35));
@@ -55,10 +60,57 @@ void main() {
expect(notificaciones, greaterThanOrEqualTo(1));
});
test('posponerAlarma nunca ancla a una proximaEjecucion futura: tras el '
'disparo nativo sincronizado, el snooze sale de la ocurrencia sonando '
'(regresion: snooze armado para manana)', () async {
// Real on-device sequence: the native FIRE fires at 22:59, onAlarmFired
// records the handled occurrence, the cold-start sync imports it and
// recalculation advances proximaEjecucion to TOMORROW — all before the
// user taps snooze on the still-ringing screen. Anchoring to
// proximaEjecucion armed "posponer 3" for tomorrow 23:02 (captured in
// logcat as snoozeCountdown remaining=1443). The anchor must be the
// ringing occurrence (ultimaEjecucionGestionada), landing today.
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final servicio = ServicioAlarmas(reloj: () => ahora);
final estado = EstadoAlarmas(
servicio: servicio,
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(alarmaDiaria('s2'));
// Native fire at 7:30; the fire-time sync marks the occurrence handled
// and recalculation moves proxima to tomorrow while the ring is live.
ahora = DateTime(2026, 6, 11, 7, 30, 20);
await servicio.sincronizarEjecucionesNativas({
's2': DateTime(2026, 6, 11, 7, 30),
});
await estado.refrescarProgramacion();
final alarma = estado.alarmas.single;
expect(
alarma.proximaEjecucion,
DateTime(2026, 6, 12, 7, 30),
reason: 'precondicion: la proxima ya avanzo a manana',
);
expect(alarma.ultimaEjecucionGestionada, DateTime(2026, 6, 11, 7, 30));
await estado.posponerAlarma(alarma, 3);
expect(
estado.alarmas.single.snoozeHasta,
DateTime(2026, 6, 11, 7, 33),
reason: 'el snooze debe sonar HOY a los 3 minutos, no manana',
);
expect(estado.alarmas.single.snoozeOrigen, DateTime(2026, 6, 11, 7, 30));
});
test(
'la lista de alarmas refleja el snooze de forma sincrona tras posponer',
() async {
final ahora = DateTime(2026, 6, 11, 7, 0);
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
@@ -69,6 +121,7 @@ void main() {
addTearDown(android.dispose);
await estado.guardarAlarma(alarmaDiaria('sync1'));
ahora = DateTime(2026, 6, 11, 7, 30, 10);
final futuro = estado.posponerAlarma(estado.alarmas.single, 10);
await futuro;
@@ -251,7 +304,7 @@ void main() {
test('posponerAlarma: cuando android.programar falla, no relanza, notifica y '
'registra el error (sin corromper el estado en memoria)', () async {
final ahora = DateTime(2026, 6, 11, 7, 0);
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
@@ -263,6 +316,7 @@ void main() {
await estado.guardarAlarma(alarmaDiaria('fail1'));
final alarma = estado.alarmas.single;
ahora = DateTime(2026, 6, 11, 7, 30, 10);
android.fallaProgramar = true;
var notificaciones = 0;
estado.addListener(() => notificaciones++);
@@ -280,7 +334,7 @@ void main() {
test('posponerAlarma: tras un fallo previo, un reintento exitoso limpia '
'estado.error (D5)', () async {
final ahora = DateTime(2026, 6, 11, 7, 0);
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
@@ -292,6 +346,7 @@ void main() {
await estado.guardarAlarma(alarmaDiaria('fail2'));
final alarma = estado.alarmas.single;
ahora = DateTime(2026, 6, 11, 7, 30, 10);
android.fallaProgramar = true;
await estado.posponerAlarma(alarma, 5);
expect(estado.error, isNotNull);
@@ -51,7 +51,10 @@ Future<_Entorno> _montarPantalla(
addTearDown(radio.dispose);
final android = FakePuertoAlarmasAndroid();
final ahora = DateTime(2026, 6, 11, 7, 0);
// Mutable clock: the alarm is saved at 7:00 and the screen mounts at ring
// time (7:30:10), so snooze anchors to the RINGING occurrence — the only
// scenario the ringing screen can exist in.
var ahora = DateTime(2026, 6, 11, 7, 0);
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
@@ -76,6 +79,8 @@ Future<_Entorno> _montarPantalla(
),
),
);
// The screen mounts at ring time: 10s after the 7:30 occurrence fired.
ahora = DateTime(2026, 6, 11, 7, 30, 10);
await tester.pumpWidget(
MultiProvider(