EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to `() => true`, so any construction site that forgot to wire entitlement compiled fine and silently ran ungated — failing OPEN to premium and disabling the paywall with no test able to catch it. The parameter is now required with no default. Production wiring in app.dart was already correct and is unchanged; the 184 pre-existing test call sites now pass `() => true` explicitly, which is exactly the old implicit default, so every assertion is untouched. EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it inherits the same contract. The one test that existed to pin the old default is renamed to describe what it still covers (the premium path through iniciar() with no duracion); its assertions are unchanged.
490 lines
16 KiB
Dart
490 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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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',
|
|
);
|
|
},
|
|
);
|
|
}
|