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.
180 lines
6.5 KiB
Dart
180 lines
6.5 KiB
Dart
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(
|
|
esPremium: () => true,
|
|
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('estado ya envenenado se cura solo: una ocurrencia futura marcada '
|
|
'como gestionada se descarta al recalcular', () async {
|
|
// Devices that ran the buggy build carry the poisoned value in
|
|
// SharedPreferences. Without this, the fix would still leave the
|
|
// affected alarm skipping one more time, with nothing in the UI to
|
|
// explain it -- and the user would reasonably read that as "not fixed".
|
|
final ahora = DateTime(2026, 8, 3, 9, 0);
|
|
final servicio = ServicioAlarmas(reloj: () => ahora);
|
|
|
|
// Saved by the buggy stop path: next Monday recorded as already handled.
|
|
await servicio.guardarAlarma(
|
|
semanalLunes(
|
|
'a3',
|
|
).copyWith(ultimaEjecucionGestionada: DateTime(2026, 8, 10, 16, 20)),
|
|
);
|
|
|
|
final config = await servicio.recalcularTodas();
|
|
final alarma = config.alarmas.single;
|
|
|
|
expect(alarma.ultimaEjecucionGestionada, isNull);
|
|
expect(
|
|
alarma.proximaEjecucion,
|
|
DateTime(2026, 8, 3, 16, 20),
|
|
reason: 'y con el dato corrupto fuera, hoy vuelve a ser candidata',
|
|
);
|
|
});
|
|
|
|
test('una ocurrencia gestionada REAL (pasada) se conserva: es la que evita '
|
|
'que la alarma vuelva a sonar en el mismo minuto', () async {
|
|
final ahora = DateTime(2026, 8, 3, 16, 20, 30);
|
|
final servicio = ServicioAlarmas(reloj: () => ahora);
|
|
final gestionada = DateTime(2026, 8, 3, 16, 20);
|
|
|
|
await servicio.guardarAlarma(
|
|
semanalLunes('a4').copyWith(ultimaEjecucionGestionada: gestionada),
|
|
);
|
|
|
|
final alarma = (await servicio.recalcularTodas()).alarmas.single;
|
|
|
|
expect(alarma.ultimaEjecucionGestionada, gestionada);
|
|
expect(
|
|
alarma.proximaEjecucion,
|
|
DateTime(2026, 8, 10, 16, 20),
|
|
reason: 'la de hoy ya sonó, la siguiente es el lunes que viene',
|
|
);
|
|
});
|
|
|
|
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(
|
|
esPremium: () => true,
|
|
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',
|
|
);
|
|
},
|
|
);
|
|
}
|