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', ); }, ); }