Files
pluriwave/test/estado/estado_alarmas_snooze_test.dart
FreeTLab 9c7cf4e261
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
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.
2026-07-12 23:17:26 +02:00

477 lines
16 KiB
Dart

import 'dart:async';
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';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
AlarmaMusical alarmaDiaria(String id, {int snoozeMinutos = 5}) =>
AlarmaMusical(
id: id,
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
snoozeMinutos: snoozeMinutos,
);
test('posponerAlarma ancla el snooze a la ocurrencia sonando, '
'programa una sola vez y notifica', () async {
// 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),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(alarmaDiaria('s1'));
final alarma = estado.alarmas.single;
expect(alarma.proximaEjecucion, DateTime(2026, 6, 11, 7, 30));
final programadasAntes = android.programadas.length;
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));
expect(estado.alarmas.single.snoozeOrigen, DateTime(2026, 6, 11, 7, 30));
expect(android.programadas.length, programadasAntes + 1);
expect(android.programadas.last.snoozeHasta, DateTime(2026, 6, 11, 7, 35));
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 {
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
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;
// Sin polls ni esperas adicionales: el estado ya refleja el snooze.
expect(
estado.alarmas.single.proximaProgramable,
DateTime(2026, 6, 11, 7, 40),
);
},
);
test(
'evento nativo snoozed registra el snooze sin reprogramar en Android',
() async {
final ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(alarmaDiaria('n1'));
final programadasAntes = android.programadas.length;
final origen = DateTime(2026, 6, 11, 7, 30);
final hasta = DateTime(2026, 6, 11, 7, 40);
final notificado = Completer<void>();
estado.addListener(() {
if (!notificado.isCompleted) notificado.complete();
});
android.emitirEvento(
EventoAlarmaAndroid(
alarmaId: 'n1',
titulo: 'Diaria',
accion: EventoAlarmaAndroid.accionSnoozed,
occurrenceAtMillis: origen.millisecondsSinceEpoch,
snoozeUntilMillis: hasta.millisecondsSinceEpoch,
),
);
await notificado.future;
expect(estado.alarmas.single.snoozeHasta, hasta);
expect(estado.alarmas.single.snoozeOrigen, origen);
expect(
android.programadas.length,
programadasAntes,
reason: 'el nativo ya reprogramo; no debe haber un segundo programar',
);
},
);
test(
'recalcularTodas tras posponer preserva snoozeHasta (guard regresion S4)',
() async {
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(alarmaDiaria('r1'));
await estado.posponerAlarma(estado.alarmas.single, 5);
final snooze = estado.alarmas.single.snoozeHasta;
expect(snooze, isNotNull);
ahora = DateTime(2026, 6, 11, 7, 2);
await estado.refrescarProgramacion();
expect(estado.alarmas.single.snoozeHasta, snooze);
expect(android.programadas.last.snoozeHasta, snooze);
},
);
test(
'inicializar importa snoozes nativos activos sin esperar el poll',
() async {
final ahora = DateTime.now();
final origen = ahora.subtract(const Duration(minutes: 2));
final hasta = ahora.add(const Duration(minutes: 8));
final servicio = ServicioAlarmas(reloj: () => ahora);
final objetivo = ahora.add(const Duration(hours: 1));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'cold1',
nombre: 'Cold start',
hora: objetivo.hour,
minuto: objetivo.minute,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final android =
FakePuertoAlarmasAndroid()
..snoozesNativos.add(
EstadoSnoozeNativo(
alarmaId: 'cold1',
snoozeHasta: hasta,
snoozeOrigen: origen,
),
);
final estado = EstadoAlarmas(
servicio: servicio,
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.inicializar();
expect(estado.alarmas.single.snoozeHasta, hasta);
expect(estado.alarmas.single.snoozeOrigen, origen);
expect(android.programadas.last.snoozeHasta, hasta);
},
);
test('desactivar una alarma pospuesta limpia el snooze (S2-R5)', () async {
final ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(alarmaDiaria('stop1'));
await estado.posponerAlarma(estado.alarmas.single, 5);
expect(estado.alarmas.single.snoozeHasta, isNotNull);
await estado.cambiarActiva(estado.alarmas.single, false);
expect(estado.alarmas.single.snoozeHasta, isNull);
expect(estado.alarmas.single.activa, isFalse);
// El puente real cancela el setAlarmClock para alarmas inactivas.
expect(android.programadas.last.activa, isFalse);
});
test(
'finalizarEjecucion limpia snooze y reprograma Android sin snooze',
() async {
final ahora = DateTime(2026, 6, 11, 7, 36);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
AlarmaMusical(
id: 'fin1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
proximaEjecucion: DateTime(2026, 6, 11, 7, 30),
snoozeHasta: DateTime(2026, 6, 11, 7, 40),
snoozeOrigen: DateTime(2026, 6, 11, 7, 30),
),
);
await estado.finalizarEjecucion('fin1');
expect(estado.alarmas.single.snoozeHasta, isNull);
expect(android.ocultadas, contains('fin1'));
expect(android.programadas.last.snoozeHasta, isNull);
},
);
test('posponerAlarma: cuando android.programar falla, no relanza, notifica y '
'registra el error (sin corromper el estado en memoria)', () async {
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
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++);
await estado.posponerAlarma(alarma, 5);
expect(estado.error, isNotNull);
expect(notificaciones, greaterThanOrEqualTo(1));
expect(
estado.alarmas.single.snoozeHasta,
DateTime(2026, 6, 11, 7, 35),
reason: 'el estado en memoria se aplica antes de la llamada nativa',
);
});
test('posponerAlarma: tras un fallo previo, un reintento exitoso limpia '
'estado.error (D5)', () async {
var ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
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);
android.fallaProgramar = false;
await estado.posponerAlarma(estado.alarmas.single, 5);
expect(estado.error, isNull);
});
test('posponerProximaDesdePreaviso: 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);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(alarmaDiaria('preaviso-fail1'));
final alarma = estado.alarmas.single;
final ejecucion = alarma.proximaEjecucion!;
android.fallaProgramar = true;
var notificaciones = 0;
estado.addListener(() => notificaciones++);
await estado.posponerProximaDesdePreaviso(alarma, 5, ejecucion);
expect(estado.error, isNotNull);
expect(notificaciones, greaterThanOrEqualTo(1));
expect(
estado.alarmas.single.snoozeHasta,
ejecucion.add(const Duration(minutes: 5)),
reason: 'el estado en memoria se aplica antes de la llamada nativa',
);
});
test('posponerProximaDesdePreaviso: tras un fallo previo, un reintento '
'exitoso limpia estado.error (D5)', () async {
final ahora = DateTime(2026, 6, 11, 7, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(alarmaDiaria('preaviso-fail2'));
final alarma = estado.alarmas.single;
final ejecucion = alarma.proximaEjecucion!;
android.fallaProgramar = true;
await estado.posponerProximaDesdePreaviso(alarma, 5, ejecucion);
expect(estado.error, isNotNull);
android.fallaProgramar = false;
await estado.posponerProximaDesdePreaviso(
estado.alarmas.single,
5,
ejecucion,
);
expect(estado.error, isNull);
});
test(
'evento nativo snoozeCancelled limpia el snooze y avanza sin reprogramar',
() async {
final ahora = DateTime(2026, 6, 11, 7, 32);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
AlarmaMusical(
id: 'cancel1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
proximaEjecucion: DateTime(2026, 6, 11, 7, 30),
snoozeHasta: DateTime(2026, 6, 11, 7, 35),
snoozeOrigen: DateTime(2026, 6, 11, 7, 30),
),
);
expect(estado.alarmas.single.snoozeHasta, isNotNull);
final programadasAntes = android.programadas.length;
final notificado = Completer<void>();
estado.addListener(() {
if (!notificado.isCompleted) notificado.complete();
});
android.emitirEvento(
EventoAlarmaAndroid(
alarmaId: 'cancel1',
titulo: 'Diaria',
accion: EventoAlarmaAndroid.accionSnoozeCancelled,
occurrenceAtMillis:
DateTime(2026, 6, 11, 7, 30).millisecondsSinceEpoch,
),
);
await notificado.future;
expect(estado.alarmas.single.snoozeHasta, isNull);
expect(
estado.alarmas.single.proximaEjecucion,
DateTime(2026, 6, 12, 7, 30),
reason: 'avanza a la próxima ocurrencia diaria',
);
expect(
android.programadas.length,
programadasAntes,
reason: 'el nativo ya reprogramó; no debe haber un segundo programar',
);
},
);
}