Files
pluriwave/test/estado/estado_alarmas_detener_ancla_test.dart
T
FreeTLab 04300592e0 fix(alarmas): heal alarms already poisoned by the old Detener anchor
The anchor fix stops NEW damage, but devices that ran the buggy build
still carry a future occurrence in ultimaEjecucionGestionada in
SharedPreferences. _esValida rejects any candidate matching it, so the
affected alarm would keep skipping that day with nothing in the UI to
explain it -- which reads as "still broken" rather than "fixed".

_recalcular now drops an ultimaEjecucionGestionada that is meaningfully
in the future. An occurrence cannot have been handled before it happens,
so such a value is corrupt by definition, and dropping it can only ever
restore a real future ring: the double-fire guard it also feeds needs a
PAST occurrence to do its job, and those are untouched.

Placed in the recalculation that every load and every mutation already
funnels through, so an affected alarm heals on the next app open with no
user action -- no delete-and-recreate.

Tests: 1122 -> 1124, including one proving a genuine past occurrence is
still preserved.
2026-08-03 22:04:37 +02:00

178 lines
6.4 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(
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(
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',
);
},
);
}