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({}); }); test('posponer persiste snooze y refrescarProgramacion no lo pisa', () async { var ahora = DateTime(2026, 5, 25, 7, 31); final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => ahora), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'a1', nombre: 'Diaria', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); final alarma = estado.alarmas.single; await estado.posponerAlarma(alarma, 5); // Ancla unificada (S2-R6): proximaEjecucion (7:31:02, normalizada por // inminencia) + 5 minutos — ya no "ahora + 5". expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36, 2)); expect( android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 36, 2), ); ahora = DateTime(2026, 5, 25, 7, 32); await estado.refrescarProgramacion(); expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36, 2)); expect( android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 36, 2), ); }); test( 'posponer desde preaviso mueve esta ejecucion desde la hora original', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( AlarmaMusical( id: 'pre1', nombre: 'Preaviso', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: const [], proximaEjecucion: DateTime(2026, 5, 25, 7, 30), ), ); final alarma = estado.alarmas.single; await estado.posponerProximaDesdePreaviso( alarma, 10, DateTime(2026, 5, 25, 7, 30), ); expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 40)); expect(estado.alarmas.single.snoozeOrigen, DateTime(2026, 5, 25, 7, 30)); expect( android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 40), ); }, ); test('finalizar diaria calcula siguiente dia y limpia snooze', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( AlarmaMusical( id: 'a2', nombre: 'Diaria', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: const [], proximaEjecucion: DateTime(2026, 5, 25, 7, 30), snoozeHasta: DateTime(2026, 5, 25, 7, 36), snoozeOrigen: DateTime(2026, 5, 25, 7, 30), ), ); await estado.finalizarEjecucion('a2'); expect(estado.alarmas.single.snoozeHasta, isNull); expect( estado.alarmas.single.proximaEjecucion, DateTime(2026, 5, 26, 7, 30), ); }); test('finalizar unica la desactiva y queda sin proxima ejecucion', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( AlarmaMusical( id: 'a3', nombre: 'Unica', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.unica, diasSemana: const [], fechaUnica: DateTime(2026, 5, 25), proximaEjecucion: DateTime(2026, 5, 25, 7, 30), ), ); await estado.finalizarEjecucion('a3'); expect(estado.alarmas.single.activa, isFalse); expect(estado.alarmas.single.proximaEjecucion, isNull); }); test( 'solicita exencion de bateria una sola vez cuando no esta exenta', () async { final android = FakePuertoAlarmasAndroid()..ignoraOptimizacionBateria = false; final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'bat1', nombre: 'Bateria', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); expect(android.solicitudesExencionBateria, 1); await estado.guardarAlarma( const AlarmaMusical( id: 'bat2', nombre: 'Bateria 2', hora: 8, minuto: 0, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); expect(android.solicitudesExencionBateria, 1); }, ); test('no solicita exencion de bateria cuando ya esta exenta', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'bat3', nombre: 'Exenta', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); expect(android.solicitudesExencionBateria, 0); }); test( 'cambiarActiva(false) detiene el audio cuando la alarma esta sonando (SS-1a)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'ring1', nombre: 'Sonando', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); android.alarmaSonandoIdValor = 'ring1'; await estado.cambiarActiva(estado.alarmas.single, false); expect(android.detencionesActivas, contains('ring1')); }, ); test( 'guardarAlarma detiene el audio cuando edita la alarma sonando (SS-1b)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'ring2', nombre: 'Sonando', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); android.alarmaSonandoIdValor = 'ring2'; await estado.guardarAlarma(estado.alarmas.single.copyWith(minuto: 45)); expect(android.detencionesActivas, contains('ring2')); }, ); test('eliminarAlarma detiene el audio antes de cancelar cuando esta sonando ' '(SS-1c, guardia de regresion)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'ring3', nombre: 'Sonando', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); android.alarmaSonandoIdValor = 'ring3'; await estado.eliminarAlarma('ring3'); expect(android.detencionesActivas, contains('ring3')); expect(android.canceladas, contains('ring3')); }); test('eliminarAlarma usa detenerSonidoNativo cuando la consulta de sonando ' 'falla (fail-toward-silence, regresion de eliminarAlarma)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'ring4', nombre: 'Sonando', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); android.fallaConsultaSonando = true; await estado.eliminarAlarma('ring4'); expect(android.detenidas, contains('ring4')); expect(android.canceladas, contains('ring4')); }); test( 'guardarAlarma (deshabilitar) usa detenerSonidoNativo cuando la consulta ' 'de sonando falla (fail-toward-silence)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'ring5', nombre: 'Sonando', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); android.fallaConsultaSonando = true; await estado.cambiarActiva(estado.alarmas.single, false); expect(android.detenidas, contains('ring5')); }, ); test( 'mutar una alarma distinta a la que suena no dispara el guard (SS-1d)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'y1', nombre: 'Sonando', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); await estado.guardarAlarma( const AlarmaMusical( id: 'z1', nombre: 'Quieta', hora: 8, minuto: 0, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); android.alarmaSonandoIdValor = 'y1'; final z1 = estado.alarmas.firstWhere((a) => a.id == 'z1'); await estado.cambiarActiva(z1, false); expect(android.detencionesActivas, isEmpty); }, ); test('finalizarEjecucion no registra error cuando el stop nativo se confirma ' '(SS-2a)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'fin1', nombre: 'Sonando', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); android.alarmaSonandoIdValor = 'fin1'; await estado.finalizarEjecucion('fin1'); expect(android.detencionesActivas, contains('fin1')); expect(estado.error, isNull); }); test('finalizarEjecucion registra error cuando el stop nativo no se confirma ' '(SS-2b)', () async { final android = FakePuertoAlarmasAndroid()..fallaDetener = true; final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'fin2', nombre: 'Sonando', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); await estado.finalizarEjecucion('fin2'); expect(estado.error, isNotNull); }); test('forzarDetencion reintenta el stop nativo y limpia el error si tiene ' 'exito (SS-3b)', () async { final android = FakePuertoAlarmasAndroid()..fallaDetener = true; final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'force1', nombre: 'Forzada', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); await estado.finalizarEjecucion('force1'); expect(estado.error, isNotNull); android.fallaDetener = false; await estado.forzarDetencion('force1'); expect(estado.error, isNull); expect(android.detencionesActivas.length, 2); }); test( 'forzarDetencion mantiene el error si el reintento tambien falla (SS-3b)', () async { final android = FakePuertoAlarmasAndroid()..fallaDetener = true; final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'force2', nombre: 'Forzada', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); await estado.finalizarEjecucion('force2'); await estado.forzarDetencion('force2'); expect(estado.error, isNotNull); }, ); test('evento nativo missed completa la ejecucion (Phase 6)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( AlarmaMusical( id: 'miss1', nombre: 'Perdida', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: const [], proximaEjecucion: DateTime(2026, 5, 25, 7, 30), ), ); final notificado = Completer(); estado.addListener(() { if (!notificado.isCompleted) notificado.complete(); }); android.emitirEvento( EventoAlarmaAndroid( alarmaId: 'miss1', titulo: 'Perdida', accion: EventoAlarmaAndroid.accionMissed, occurrenceAtMillis: DateTime(2026, 5, 25, 7, 30).millisecondsSinceEpoch, ), ); await notificado.future; expect( estado.alarmas.single.proximaEjecucion, DateTime(2026, 5, 26, 7, 30), ); }); test( 'inicializar sincroniza ejecucion nativa y evita reprogramar al instante', () async { final android = FakePuertoAlarmasAndroid() ..ejecucionesNativas.add( EjecucionAlarmaNativa( alarmaId: 'native1', gestionadaEn: DateTime(2026, 5, 25, 7, 30), ), ); final servicio = ServicioAlarmas( reloj: () => DateTime(2026, 5, 25, 7, 30, 20), ); await servicio.guardarAlarma( AlarmaMusical( id: 'native1', nombre: 'Nativa', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: const [], proximaEjecucion: DateTime(2026, 5, 25, 7, 30), ), ); final estado = EstadoAlarmas( servicio: servicio, android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.inicializar(); expect( estado.alarmas.single.ultimaEjecucionGestionada, DateTime(2026, 5, 25, 7, 30), ); expect( estado.alarmas.single.proximaEjecucion, DateTime(2026, 5, 26, 7, 30), ); expect( android.programadas.last.proximaProgramable, DateTime(2026, 5, 26, 7, 30), ); }, ); group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () { test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye ' '"ahora" (dias restantes derivables de finDia), o null si ninguno ' 'calza (fixed ahora)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarVacaciones([ RangoVacaciones( id: 'v1', nombre: 'Julio', inicio: DateTime(2026, 7, 5), fin: DateTime(2026, 7, 15), ), ]); final activo = estado.rangoVacacionesActivo(ahora: DateTime(2026, 7, 10)); expect(activo?.id, 'v1'); expect(activo!.finDia.difference(DateTime(2026, 7, 10)).inDays, 5); expect(estado.rangoVacacionesActivo(ahora: DateTime(2026, 8, 1)), isNull); }); test('vacacionesProximas devuelve los rangos futuros ordenados por inicio ' 'ascendente; vacacionesPasadas los finalizados por fin descendente; ' 'ambas son disjuntas del rango activo', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarVacaciones([ RangoVacaciones( id: 'activo', nombre: 'Activo', inicio: DateTime(2026, 7, 5), fin: DateTime(2026, 7, 15), ), RangoVacaciones( id: 'lejos', nombre: 'Lejos', inicio: DateTime(2026, 12, 1), fin: DateTime(2026, 12, 10), ), RangoVacaciones( id: 'cerca', nombre: 'Cerca', inicio: DateTime(2026, 8, 1), fin: DateTime(2026, 8, 5), ), RangoVacaciones( id: 'viejo', nombre: 'Viejo', inicio: DateTime(2026, 1, 1), fin: DateTime(2026, 1, 10), ), RangoVacaciones( id: 'reciente', nombre: 'Reciente', inicio: DateTime(2026, 6, 1), fin: DateTime(2026, 6, 5), ), ]); final ahora = DateTime(2026, 7, 10); final proximas = estado.vacacionesProximas(ahora: ahora); expect(proximas.map((r) => r.id).toList(), ['cerca', 'lejos']); final pasadas = estado.vacacionesPasadas(ahora: ahora); expect(pasadas.map((r) => r.id).toList(), ['reciente', 'viejo']); expect(proximas.any((r) => r.id == 'activo'), isFalse); expect(pasadas.any((r) => r.id == 'activo'), isFalse); }); test('impactoDeRango separa alarmas activas pausadas de las no afectadas ' 'segun sonarEnVacaciones, igual que el predicado del planificador ' '(servicio_programacion_alarmas.dart)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'p1', nombre: 'Pausada 1', hora: 7, minuto: 30, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], sonarEnVacaciones: false, ), ); await estado.guardarAlarma( const AlarmaMusical( id: 'p2', nombre: 'Pausada 2', hora: 13, minuto: 45, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], sonarEnVacaciones: false, ), ); await estado.guardarAlarma( const AlarmaMusical( id: 'n1', nombre: 'Sigue', hora: 8, minuto: 15, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); final rango = RangoVacaciones( id: 'v1', nombre: 'Julio', inicio: DateTime(2026, 7, 5), fin: DateTime(2026, 7, 15), ); await estado.guardarVacaciones([rango]); final impacto = estado.impactoDeRango(rango); expect(impacto.pausadas.map((a) => a.id).toSet(), {'p1', 'p2'}); expect(impacto.noAfectadas.map((a) => a.id).toSet(), {'n1'}); }); test('impactoDeRango no cuenta alarmas inactivas ni como pausadas ni como ' 'no afectadas', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'inactiva', nombre: 'Apagada', hora: 7, minuto: 0, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], sonarEnVacaciones: false, activa: false, ), ); final rango = RangoVacaciones( id: 'v1', nombre: 'Julio', inicio: DateTime(2026, 7, 5), fin: DateTime(2026, 7, 15), ); await estado.guardarVacaciones([rango]); final impacto = estado.impactoDeRango(rango); expect(impacto.pausadas, isEmpty); expect(impacto.noAfectadas, isEmpty); }); test('ninguno de los 4 metodos de consulta reprograma Android (guardia de ' 'pureza — son solo lectura sobre _alarmas/_vacaciones)', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)), android: android, iniciarAutomaticamente: false, ); addTearDown(estado.dispose); addTearDown(android.dispose); await estado.guardarAlarma( const AlarmaMusical( id: 'q1', nombre: 'Consulta', hora: 7, minuto: 0, tipoProgramacion: TipoProgramacionAlarma.diaria, diasSemana: [], ), ); final rango = RangoVacaciones( id: 'v1', nombre: 'Julio', inicio: DateTime(2026, 7, 5), fin: DateTime(2026, 7, 15), ); await estado.guardarVacaciones([rango]); final programadasAntes = android.programadas.length; estado.rangoVacacionesActivo(); estado.vacacionesProximas(); estado.vacacionesPasadas(); estado.impactoDeRango(rango); expect(android.programadas.length, programadasAntes); }); }); }