merge: fix Detener consuming a future alarm occurrence
This commit is contained in:
@@ -316,29 +316,47 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
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.
|
||||
/// The occurrence that is ACTUALLY ringing right now — the anchor both
|
||||
/// ring-screen actions (Posponer and Detener) must close.
|
||||
///
|
||||
/// It is NEVER a future occurrence. When the native fire works, the
|
||||
/// fire-time sync advances `proximaEjecucion` to the next one before the
|
||||
/// user can even reach the ring screen, so taking `proximaEjecucion`
|
||||
/// unguarded closes an occurrence that has not happened yet. For snooze
|
||||
/// that showed up as "posponer 3" arming a full day out (observed
|
||||
/// on-device: tomorrow 23:02). For stop it was worse and silent: the
|
||||
/// future occurrence was recorded in `ultimaEjecucionGestionada`, which
|
||||
/// `ServicioProgramacionAlarmas._esValida` then rejects for real — so a
|
||||
/// Monday-only alarm stopped today simply never rang next Monday, and
|
||||
/// every sibling alarm outranked it in the "next alarm" banner.
|
||||
///
|
||||
/// The candidates, newest first, each gated on "not meaningfully in the
|
||||
/// future": [AlarmaMusical.snoozeOrigen] (a re-snooze keeps the original
|
||||
/// anchor), then [AlarmaMusical.proximaEjecucion] (watchdog path: still
|
||||
/// today's just-due occurrence), then
|
||||
/// [AlarmaMusical.ultimaEjecucionGestionada] (native-fire path: the sync
|
||||
/// recorded the ringing occurrence there), then now.
|
||||
///
|
||||
/// ONE helper for BOTH callers on purpose. This guard was written for
|
||||
/// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten
|
||||
/// lines below with the identical hazard and no guard, and it stayed that
|
||||
/// way until a user lost a whole week of alarms. Do not re-inline it.
|
||||
DateTime _ocurrenciaSonando(AlarmaMusical? alarma) {
|
||||
final ahora = servicio.ahora();
|
||||
final limite = ahora.add(
|
||||
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
||||
);
|
||||
DateTime? sonando(DateTime? candidata) =>
|
||||
candidata != null && !candidata.isAfter(limite) ? candidata : null;
|
||||
final ejecucion =
|
||||
sonando(alarma.snoozeOrigen) ??
|
||||
sonando(alarma.proximaEjecucion) ??
|
||||
sonando(alarma.ultimaEjecucionGestionada) ??
|
||||
return sonando(alarma?.snoozeOrigen) ??
|
||||
sonando(alarma?.proximaEjecucion) ??
|
||||
sonando(alarma?.ultimaEjecucionGestionada) ??
|
||||
ahora;
|
||||
}
|
||||
|
||||
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
||||
_error = null;
|
||||
final ejecucion = _ocurrenciaSonando(alarma);
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
|
||||
);
|
||||
@@ -401,11 +419,11 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
|
||||
_error = null;
|
||||
final alarma = _buscarAlarma(alarmaId);
|
||||
final ejecucion =
|
||||
alarma?.snoozeOrigen ??
|
||||
alarma?.proximaEjecucion ??
|
||||
alarma?.snoozeHasta ??
|
||||
DateTime.now();
|
||||
// Same anchor as posponerAlarma, through the same helper: closing a
|
||||
// future occurrence here marks it handled, and _esValida then skips it
|
||||
// for real -- the alarm silently never rings that day. See
|
||||
// [_ocurrenciaSonando].
|
||||
final ejecucion = _ocurrenciaSonando(alarma);
|
||||
await android.ocultarNotificacionAlarma(alarmaId);
|
||||
// Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the
|
||||
// id-agnostic fail-safe stop directly (it always targets whatever is
|
||||
|
||||
@@ -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