merge: fix Detener consuming a future alarm occurrence

This commit is contained in:
2026-08-03 21:59:30 +02:00
2 changed files with 169 additions and 21 deletions
+39 -21
View File
@@ -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',
);
},
);
}