From 28b663bbe7e7c0935fd9c13b56b1b150a8d9fcb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Bautista=20Fern=C3=A1ndez?= Date: Thu, 2 Jul 2026 15:21:38 +0200 Subject: [PATCH] fix(alarm): recalculate every alarm on each mutation, not just the touched one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guardarAlarma only recomputed proximaEjecucion for the alarm being saved; every other alarm kept whatever snapshot the last periodic recalculation left, which can be stale or already past-due. Since EstadoAlarmas.proximaAlarma just sorts by proximaProgramable, a stale sibling could wrongly outrank a freshly activated/created/edited alarm in the "Próxima alarma" panel until the next 1-minute tick. Extended the same full-list recalculation guardarVacaciones already did to guardarAlarma, eliminarAlarma, completarEjecucion, sincronizarEjecucionesNativas, saltarProxima and posponerEjecucionHasta, via a shared _recalcularLista helper. --- lib/servicios/servicio_alarmas.dart | 123 ++++++++++-------- .../servicio_alarmas_proxima_test.dart | 74 +++++++++++ 2 files changed, 142 insertions(+), 55 deletions(-) create mode 100644 test/servicios/servicio_alarmas_proxima_test.dart diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 716d9f5..c8bb5db 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -116,23 +116,32 @@ class ServicioAlarmas { }) => _enCola(() async { final config = await _configActual(); final ahora = _reloj(); + final vacacionesActuales = vacaciones ?? config.vacaciones; + final excepcionesActuales = excepciones ?? config.excepciones; final alarmas = List.from(config.alarmas); final index = alarmas.indexWhere((a) => a.id == alarma.id); - final normalizada = _recalcular( - alarma.copyWith(creadaEn: alarma.creadaEn ?? ahora, actualizadaEn: ahora), - vacaciones ?? config.vacaciones, - excepciones ?? config.excepciones, + final actualizada = alarma.copyWith( + creadaEn: alarma.creadaEn ?? ahora, + actualizadaEn: ahora, ); - if (index >= 0) { - alarmas[index] = normalizada; + alarmas[index] = actualizada; } else { - alarmas.add(normalizada); + alarmas.add(actualizada); } + // Recalculate every alarm, not just the one just saved: siblings can + // hold a proximaEjecucion snapshot from the last periodic tick that is + // stale (even past-due), which would otherwise outrank a freshly + // created/activated/edited alarm when EstadoAlarmas.proximaAlarma sorts + // by proximaProgramable. final nuevo = ConfiguracionAlarmas( - alarmas: alarmas, - vacaciones: vacaciones ?? config.vacaciones, - excepciones: excepciones ?? config.excepciones, + alarmas: _recalcularLista( + alarmas, + vacacionesActuales, + excepcionesActuales, + ), + vacaciones: vacacionesActuales, + excepciones: excepcionesActuales, ); await _guardar(nuevo); return nuevo; @@ -140,10 +149,13 @@ class ServicioAlarmas { Future eliminarAlarma(String id) => _enCola(() async { final config = await _configActual(); + final restantes = config.alarmas.where((a) => a.id != id).toList(); + final excepciones = + config.excepciones.where((e) => e.alarmaId != id).toList(); final nuevo = ConfiguracionAlarmas( - alarmas: config.alarmas.where((a) => a.id != id).toList(), + alarmas: _recalcularLista(restantes, config.vacaciones, excepciones), vacaciones: config.vacaciones, - excepciones: config.excepciones.where((e) => e.alarmaId != id).toList(), + excepciones: excepciones, ); await _guardar(nuevo); return nuevo; @@ -156,12 +168,12 @@ class ServicioAlarmas { final normalizadas = vacaciones.map((v) => v.normalizado()).toList() ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); - final alarmas = - config.alarmas - .map((a) => _recalcular(a, normalizadas, config.excepciones)) - .toList(); final nuevo = ConfiguracionAlarmas( - alarmas: alarmas, + alarmas: _recalcularLista( + config.alarmas, + normalizadas, + config.excepciones, + ), vacaciones: normalizadas, excepciones: config.excepciones, ); @@ -188,10 +200,11 @@ class ServicioAlarmas { Future recalcularTodas() => _enCola(() async { final config = await _configActual(); - final alarmas = - config.alarmas - .map((a) => _recalcular(a, config.vacaciones, config.excepciones)) - .toList(); + final alarmas = _recalcularLista( + config.alarmas, + config.vacaciones, + config.excepciones, + ); final nuevo = ConfiguracionAlarmas( alarmas: alarmas, vacaciones: config.vacaciones, @@ -258,7 +271,7 @@ class ServicioAlarmas { if (!huboCambios) return config; final nuevo = ConfiguracionAlarmas( - alarmas: alarmas, + alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones), vacaciones: config.vacaciones, excepciones: config.excepciones, ); @@ -266,38 +279,26 @@ class ServicioAlarmas { return nuevo; } - Future saltarProxima(String alarmaId) => - _enCola(() async { - final config = await _configActual(); - final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId); - final proxima = alarma.proximaEjecucion; - if (proxima == null) return config; + Future saltarProxima( + String alarmaId, + ) => _enCola(() async { + final config = await _configActual(); + final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId); + final proxima = alarma.proximaEjecucion; + if (proxima == null) return config; - final excepciones = [ - ...config.excepciones, - ExcepcionAlarma( - alarmaId: alarmaId, - ejecucion: proxima, - tipo: 'skipNext', - ), - ]; - final alarmas = - config.alarmas - .map( - (a) => - a.id == alarmaId - ? _recalcular(a, config.vacaciones, excepciones) - : a, - ) - .toList(); - final nuevo = ConfiguracionAlarmas( - alarmas: alarmas, - vacaciones: config.vacaciones, - excepciones: excepciones, - ); - await _guardar(nuevo); - return nuevo; - }); + final excepciones = [ + ...config.excepciones, + ExcepcionAlarma(alarmaId: alarmaId, ejecucion: proxima, tipo: 'skipNext'), + ]; + final nuevo = ConfiguracionAlarmas( + alarmas: _recalcularLista(config.alarmas, config.vacaciones, excepciones), + vacaciones: config.vacaciones, + excepciones: excepciones, + ); + await _guardar(nuevo); + return nuevo; + }); Future posponerEjecucion( String alarmaId, @@ -340,7 +341,7 @@ class ServicioAlarmas { ) .toList(); final nuevo = ConfiguracionAlarmas( - alarmas: alarmas, + alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones), vacaciones: config.vacaciones, excepciones: config.excepciones, ); @@ -376,7 +377,7 @@ class ServicioAlarmas { ); }).toList(); final nuevo = ConfiguracionAlarmas( - alarmas: alarmas, + alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones), vacaciones: config.vacaciones, excepciones: config.excepciones, ); @@ -459,6 +460,18 @@ class ServicioAlarmas { ); } + // Every mutation that can shift "which alarm fires next" (create, edit, + // activate/deactivate, delete, fire, snooze, skip) recalculates the whole + // list, not just the alarm it touched directly. Siblings otherwise keep a + // proximaEjecucion snapshot from the last periodic tick, which can be + // stale (or already past-due) and wrongly outrank a freshly updated alarm + // wherever proximaProgramable values are compared (EstadoAlarmas.proximaAlarma). + List _recalcularLista( + List alarmas, + List vacaciones, + List excepciones, + ) => alarmas.map((a) => _recalcular(a, vacaciones, excepciones)).toList(); + Future _resolverPrefs() async => _prefs ?? SharedPreferences.getInstance(); } diff --git a/test/servicios/servicio_alarmas_proxima_test.dart b/test/servicios/servicio_alarmas_proxima_test.dart new file mode 100644 index 0000000..4234c12 --- /dev/null +++ b/test/servicios/servicio_alarmas_proxima_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// SharedPreferences spy: only the members ServicioAlarmas touches are +/// implemented; everything else throws via noSuchMethod. +class _PrefsEspia implements SharedPreferences { + final Map _datos = {}; + + @override + String? getString(String key) => _datos[key] as String?; + + @override + Future setString(String key, String value) async { + _datos[key] = value; + return true; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + // Regression for: activating/creating/deleting/firing an alarm must never + // leave a sibling stuck with a stale (already past-due) proximaEjecucion + // snapshot, since EstadoAlarmas.proximaAlarma just sorts by + // proximaProgramable and a stale past value would incorrectly outrank a + // freshly computed future one. + test( + 'guardarAlarma recalcula la proximaEjecucion de TODAS las alarmas, no solo la guardada', + () async { + final prefs = _PrefsEspia(); + var reloj = DateTime(2026, 1, 1, 6, 0); + final servicio = ServicioAlarmas(prefs: prefs, reloj: () => reloj); + + final a = servicio.crearAlarma( + nombre: 'A', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + final configTrasA = await servicio.guardarAlarma(a); + final aGuardada = configTrasA.alarmas.single; + expect(aGuardada.proximaEjecucion, DateTime(2026, 1, 1, 7, 0)); + + // Time moves forward well past A's stored occurrence, simulating the + // periodic 1-minute refresh not having ticked yet. + reloj = DateTime(2026, 1, 2, 8, 0); + + final b = servicio.crearAlarma( + nombre: 'B', + hora: 9, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + final configTrasB = await servicio.guardarAlarma(b); + + final aTrasGuardarB = configTrasB.alarmas.firstWhere( + (alarma) => alarma.id == a.id, + ); + expect( + aTrasGuardarB.proximaEjecucion!.isAfter(reloj), + isTrue, + reason: + 'A debe recalcularse al guardar B, no quedar con el snapshot ' + 'viejo (ya vencido) que la haria ganar la comparacion de ' + '"proxima alarma" por error', + ); + }, + ); +}