posponerAlarma() and posponerProximaDesdePreaviso() called the native scheduler with no error handling, unlike guardarAlarma(). When the native call failed (e.g. revoked exact-alarm permission), the exception escaped before notifyListeners() ran, leaving the alarm list stuck on stale data with no real alarm scheduled and no snooze countdown notification. Both methods now mirror guardarAlarma()'s pattern: permission pre-check, try/catch into _error, and an unconditional notifyListeners() so the UI always reflects the outcome. Failures surface via SnackBar in the ringing screen and in app.dart's postpone-next handler.
422 lines
14 KiB
Dart
422 lines
14 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 snoozeHasta en proximaEjecucion + minutos, '
|
|
'programa una sola vez y notifica', () 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('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++);
|
|
|
|
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(
|
|
'la lista de alarmas refleja el snooze de forma sincrona tras posponer',
|
|
() 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('sync1'));
|
|
|
|
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 {
|
|
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('fail1'));
|
|
final alarma = estado.alarmas.single;
|
|
|
|
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 {
|
|
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('fail2'));
|
|
final alarma = estado.alarmas.single;
|
|
|
|
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',
|
|
);
|
|
},
|
|
);
|
|
}
|