fix(alarm): anchor snooze to the ringing occurrence, never a future one
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:
@@ -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()}',
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user