merge: fix Detener consuming a future alarm occurrence
This commit is contained in:
@@ -316,29 +316,47 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
/// The occurrence that is ACTUALLY ringing right now — the anchor both
|
||||||
_error = null;
|
/// ring-screen actions (Posponer and Detener) must close.
|
||||||
// The snooze anchors to the occurrence that is RINGING — never a future
|
///
|
||||||
// one. When the native fire works, the fire-time sync advances
|
/// It is NEVER a future occurrence. When the native fire works, the
|
||||||
// proximaEjecucion to the NEXT day before the user can even tap snooze,
|
/// fire-time sync advances `proximaEjecucion` to the next one before the
|
||||||
// so anchoring to proximaEjecucion re-armed "posponer 3" a full day out
|
/// user can even reach the ring screen, so taking `proximaEjecucion`
|
||||||
// (observed on-device: snooze armed for tomorrow 23:02). The ringing
|
/// unguarded closes an occurrence that has not happened yet. For snooze
|
||||||
// occurrence is the newest candidate not meaningfully in the future:
|
/// that showed up as "posponer 3" arming a full day out (observed
|
||||||
// snoozeOrigen (a re-snooze keeps the original anchor), then
|
/// on-device: tomorrow 23:02). For stop it was worse and silent: the
|
||||||
// proximaEjecucion (watchdog path: still today's just-due occurrence),
|
/// future occurrence was recorded in `ultimaEjecucionGestionada`, which
|
||||||
// then ultimaEjecucionGestionada (native-fire path: the sync recorded
|
/// `ServicioProgramacionAlarmas._esValida` then rejects for real — so a
|
||||||
// the ringing occurrence there), then now.
|
/// 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 ahora = servicio.ahora();
|
||||||
final limite = ahora.add(
|
final limite = ahora.add(
|
||||||
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
||||||
);
|
);
|
||||||
DateTime? sonando(DateTime? candidata) =>
|
DateTime? sonando(DateTime? candidata) =>
|
||||||
candidata != null && !candidata.isAfter(limite) ? candidata : null;
|
candidata != null && !candidata.isAfter(limite) ? candidata : null;
|
||||||
final ejecucion =
|
return sonando(alarma?.snoozeOrigen) ??
|
||||||
sonando(alarma.snoozeOrigen) ??
|
sonando(alarma?.proximaEjecucion) ??
|
||||||
sonando(alarma.proximaEjecucion) ??
|
sonando(alarma?.ultimaEjecucionGestionada) ??
|
||||||
sonando(alarma.ultimaEjecucionGestionada) ??
|
|
||||||
ahora;
|
ahora;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
||||||
|
_error = null;
|
||||||
|
final ejecucion = _ocurrenciaSonando(alarma);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
|
'[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');
|
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
|
||||||
_error = null;
|
_error = null;
|
||||||
final alarma = _buscarAlarma(alarmaId);
|
final alarma = _buscarAlarma(alarmaId);
|
||||||
final ejecucion =
|
// Same anchor as posponerAlarma, through the same helper: closing a
|
||||||
alarma?.snoozeOrigen ??
|
// future occurrence here marks it handled, and _esValida then skips it
|
||||||
alarma?.proximaEjecucion ??
|
// for real -- the alarm silently never rings that day. See
|
||||||
alarma?.snoozeHasta ??
|
// [_ocurrenciaSonando].
|
||||||
DateTime.now();
|
final ejecucion = _ocurrenciaSonando(alarma);
|
||||||
await android.ocultarNotificacionAlarma(alarmaId);
|
await android.ocultarNotificacionAlarma(alarmaId);
|
||||||
// Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the
|
// Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the
|
||||||
// id-agnostic fail-safe stop directly (it always targets whatever is
|
// 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