diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 2584d76..a51d0f3 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -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 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()}', ); diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 3769420..adadf0b 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -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. diff --git a/test/estado/estado_alarmas_snooze_test.dart b/test/estado/estado_alarmas_snooze_test.dart index f97cb3a..c551a72 100644 --- a/test/estado/estado_alarmas_snooze_test.dart +++ b/test/estado/estado_alarmas_snooze_test.dart @@ -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); diff --git a/test/pantallas/pantalla_alarma_sonando_test.dart b/test/pantallas/pantalla_alarma_sonando_test.dart index b93c811..cb900ac 100644 --- a/test/pantallas/pantalla_alarma_sonando_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_test.dart @@ -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(