feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and PluriWaveAlarmService record a pre-notice that could not be armed, a refused foreground-service start, and a per-alarm reschedule that failed after a reboot -- but nothing read them, so all three still ended at logcat. EstadoAlarmas now drains them at startup and turns each into a per-alarm exception, which the card already knows how to mark. An alarm that never reached the OS stops looking identical to one that did. The read is deliberately tolerant: a failure to read is logged and swallowed, never surfaced as an alarm error, so a diagnostics gap cannot masquerade as a scheduling problem.
This commit is contained in:
@@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
|
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
|
||||||
|
NativeSchedulingFailures.clear(
|
||||||
|
appContext,
|
||||||
|
spec.id,
|
||||||
|
NativeSchedulingFailures.TYPE_PRE_NOTICE
|
||||||
|
)
|
||||||
} catch (_: SecurityException) {
|
} catch (_: SecurityException) {
|
||||||
|
// Silent before this fix: the main alarm can still arm via
|
||||||
|
// setAlarmClock (exempt from the exact-alarm permission), so
|
||||||
|
// the alarm itself rings while its 30-minute reminder simply
|
||||||
|
// never appears, with nothing surfaced anywhere but logcat.
|
||||||
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
|
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
|
||||||
|
NativeSchedulingFailures.record(
|
||||||
|
appContext,
|
||||||
|
spec.id,
|
||||||
|
NativeSchedulingFailures.TYPE_PRE_NOTICE
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if (spec.triggerAtMillis > now) {
|
} else if (spec.triggerAtMillis > now) {
|
||||||
appContext.sendBroadcast(
|
appContext.sendBroadcast(
|
||||||
@@ -846,8 +860,22 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
// the native recompute inside scheduleSpec.
|
// the native recompute inside scheduleSpec.
|
||||||
scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true)
|
scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true)
|
||||||
Log.d(tag, "alarm.reschedule OK id=$id")
|
Log.d(tag, "alarm.reschedule OK id=$id")
|
||||||
|
NativeSchedulingFailures.clear(
|
||||||
|
appContext,
|
||||||
|
id,
|
||||||
|
NativeSchedulingFailures.TYPE_RESCHEDULE
|
||||||
|
)
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
|
// Silent before this fix: one alarm's reschedule failure used
|
||||||
|
// to just log and move to the next id, leaving that ONE
|
||||||
|
// alarm unscheduled after a reboot/unlock/app-update with no
|
||||||
|
// signal anywhere but logcat.
|
||||||
Log.e(tag, "alarm.reschedule failed id=$id", error)
|
Log.e(tag, "alarm.reschedule failed id=$id", error)
|
||||||
|
NativeSchedulingFailures.record(
|
||||||
|
appContext,
|
||||||
|
id,
|
||||||
|
NativeSchedulingFailures.TYPE_RESCHEDULE
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -855,6 +883,18 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
fun pendingAlarmCount(): Int =
|
fun pendingAlarmCount(): Int =
|
||||||
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
|
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduling-reliability failures the native side recorded on its own
|
||||||
|
* (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground-
|
||||||
|
* service start, and post-boot/unlock reschedule failures never go
|
||||||
|
* through a Dart method-channel call that could throw, so they are
|
||||||
|
* persisted here instead and synced by Flutter on the next app launch
|
||||||
|
* -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own
|
||||||
|
* cold-start-sync shape.
|
||||||
|
*/
|
||||||
|
fun scheduleFailures(): List<Map<String, Any>> =
|
||||||
|
NativeSchedulingFailures.all(appContext)
|
||||||
|
|
||||||
fun handledOccurrences(): List<Map<String, Any>> =
|
fun handledOccurrences(): List<Map<String, Any>> =
|
||||||
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
|
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
|
||||||
.mapNotNull { id ->
|
.mapNotNull { id ->
|
||||||
@@ -1265,3 +1305,91 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
|
|
||||||
private fun JSONObject.optNullableLong(name: String): Long? =
|
private fun JSONObject.optNullableLong(name: String): Long? =
|
||||||
if (has(name) && !isNull(name)) optLong(name) else null
|
if (has(name) && !isNull(name)) optLong(name) else null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted store for scheduling-reliability failures the native side
|
||||||
|
* catches and previously only logged (fix/alarmas-fallos-silenciosos, item
|
||||||
|
* 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice),
|
||||||
|
* a refused foreground-service start (PluriWaveAlarmService.start), and a
|
||||||
|
* per-alarm reschedule failure after boot/unlock
|
||||||
|
* (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object
|
||||||
|
* (not nested in [AlarmScheduler]'s own instance state) so
|
||||||
|
* [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler]
|
||||||
|
* instance of its own -- can record a failure too, using the exact same
|
||||||
|
* [Context]-scoped, device-protected-storage `SharedPreferences` file
|
||||||
|
* [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by
|
||||||
|
* hand since Kotlin constants cannot be shared across files without a third
|
||||||
|
* file).
|
||||||
|
*
|
||||||
|
* Only the LATEST failure per alarm is kept (mirrors the Dart-side
|
||||||
|
* `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability
|
||||||
|
* signal, not an audit log.
|
||||||
|
*/
|
||||||
|
object NativeSchedulingFailures {
|
||||||
|
private const val PREFS = "pluriwave_alarm_scheduler"
|
||||||
|
private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids"
|
||||||
|
private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_"
|
||||||
|
private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_"
|
||||||
|
|
||||||
|
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */
|
||||||
|
const val TYPE_PRE_NOTICE = "preNoticeFailed"
|
||||||
|
|
||||||
|
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */
|
||||||
|
const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed"
|
||||||
|
|
||||||
|
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */
|
||||||
|
const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed"
|
||||||
|
|
||||||
|
private fun prefs(context: Context) =
|
||||||
|
context.applicationContext.createDeviceProtectedStorageContext()
|
||||||
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun record(context: Context, id: String, type: String) {
|
||||||
|
if (id.isBlank()) return
|
||||||
|
val store = prefs(context)
|
||||||
|
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
|
||||||
|
ids.add(id)
|
||||||
|
store.edit()
|
||||||
|
.putStringSet(KEY_FAILURE_IDS, ids)
|
||||||
|
.putString("$KEY_FAILURE_TYPE_PREFIX$id", type)
|
||||||
|
.putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the failure recorded for [id] ONLY when its current type is
|
||||||
|
* [type] -- type-scoped on purpose (mirrors the Dart-side
|
||||||
|
* `limpiarFalloProgramacion`), so a foreground-service success never
|
||||||
|
* erases an unrelated, still-outstanding pre-notice failure for the
|
||||||
|
* same alarm.
|
||||||
|
*/
|
||||||
|
fun clear(context: Context, id: String, type: String) {
|
||||||
|
val store = prefs(context)
|
||||||
|
val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
|
||||||
|
if (storedType != type) return
|
||||||
|
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
|
||||||
|
if (!ids.remove(id)) return
|
||||||
|
store.edit()
|
||||||
|
.putStringSet(KEY_FAILURE_IDS, ids)
|
||||||
|
.remove("$KEY_FAILURE_TYPE_PREFIX$id")
|
||||||
|
.remove("$KEY_FAILURE_AT_PREFIX$id")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun all(context: Context): List<Map<String, Any>> {
|
||||||
|
val store = prefs(context)
|
||||||
|
return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id ->
|
||||||
|
val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
|
||||||
|
?: return@mapNotNull null
|
||||||
|
val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L)
|
||||||
|
.takeIf { it > 0L }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
mapOf(
|
||||||
|
"alarmId" to id,
|
||||||
|
"type" to type,
|
||||||
|
"atMillis" to at
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
Log.d(tag, "alarm.channel getNativeSnoozeState")
|
Log.d(tag, "alarm.channel getNativeSnoozeState")
|
||||||
result.success(alarmScheduler.nativeSnoozeStates())
|
result.success(alarmScheduler.nativeSnoozeStates())
|
||||||
}
|
}
|
||||||
|
"getNativeSchedulingFailures" -> {
|
||||||
|
Log.d(tag, "alarm.channel getNativeSchedulingFailures")
|
||||||
|
result.success(alarmScheduler.scheduleFailures())
|
||||||
|
}
|
||||||
"setNotificationStrings" -> {
|
"setNotificationStrings" -> {
|
||||||
val args = call.arguments as? Map<*, *>
|
val args = call.arguments as? Map<*, *>
|
||||||
if (args != null) {
|
if (args != null) {
|
||||||
|
|||||||
@@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() {
|
|||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
|
// Silent before this fix: same user-visible symptom as a refused
|
||||||
|
// startForegroundService (the ring never actually starts) --
|
||||||
|
// recorded under the SAME tipo so the alarms list surfaces it
|
||||||
|
// regardless of which of the two calls the OS refused.
|
||||||
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
|
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
|
||||||
|
NativeSchedulingFailures.record(
|
||||||
|
this,
|
||||||
|
alarmId,
|
||||||
|
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||||
|
)
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
// Second documented clear site (feedback item, READ-5): this
|
// Second documented clear site (feedback item, READ-5): this
|
||||||
// branch never reaches stopEverything(), so without the same
|
// branch never reaches stopEverything(), so without the same
|
||||||
@@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
NativeSchedulingFailures.clear(
|
||||||
|
this,
|
||||||
|
alarmId,
|
||||||
|
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||||
|
)
|
||||||
startAudio(
|
startAudio(
|
||||||
alarmId,
|
alarmId,
|
||||||
stationName,
|
stationName,
|
||||||
@@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() {
|
|||||||
|
|
||||||
fun start(context: Context, source: Intent) {
|
fun start(context: Context, source: Intent) {
|
||||||
ensureChannel(context)
|
ensureChannel(context)
|
||||||
|
val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
||||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
||||||
putExtras(source)
|
putExtras(source)
|
||||||
@@ -761,8 +776,27 @@ class PluriWaveAlarmService : Service() {
|
|||||||
try {
|
try {
|
||||||
ContextCompat.startForegroundService(context, intent)
|
ContextCompat.startForegroundService(context, intent)
|
||||||
Log.d(TAG, "alarm.service start requested")
|
Log.d(TAG, "alarm.service start requested")
|
||||||
|
if (!alarmId.isNullOrBlank()) {
|
||||||
|
NativeSchedulingFailures.clear(
|
||||||
|
context,
|
||||||
|
alarmId,
|
||||||
|
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
|
// Silent before this fix: a fire-and-forget call from the
|
||||||
|
// receiver's ACTION_FIRE branch -- if the OS refuses the
|
||||||
|
// foreground-service start (background-restricted app), the
|
||||||
|
// ring never happens and nothing surfaced it anywhere but
|
||||||
|
// logcat, "as if there were no alarm at all".
|
||||||
Log.e(TAG, "alarm.service start failed", error)
|
Log.e(TAG, "alarm.service start failed", error)
|
||||||
|
if (!alarmId.isNullOrBlank()) {
|
||||||
|
NativeSchedulingFailures.record(
|
||||||
|
context,
|
||||||
|
alarmId,
|
||||||
|
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
await _sincronizarTodas();
|
await _sincronizarTodas();
|
||||||
await cargarDiagnostico();
|
await cargarDiagnostico();
|
||||||
|
await cargarFallosNativos();
|
||||||
_activarRefresco();
|
_activarRefresco();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'No se pudieron cargar las alarmas: $e';
|
_error = 'No se pudieron cargar las alarmas: $e';
|
||||||
@@ -228,10 +229,22 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Clears a previously recorded scheduling failure once a later attempt
|
/// Clears a previously recorded scheduling failure once a later attempt
|
||||||
/// for the same alarm succeeds (D5-style recovery, mirroring how [_error]
|
/// for the same alarm succeeds (D5-style recovery, mirroring how [_error]
|
||||||
/// itself already clears on a successful retry).
|
/// itself already clears on a successful retry). Type-scoped: a
|
||||||
|
/// successful `android.programar` call only proves the MAIN alarm
|
||||||
|
/// registration (and, transitively, that any stale post-boot reschedule
|
||||||
|
/// failure no longer applies) -- it says nothing about the pre-notice or
|
||||||
|
/// foreground-service subsystems, so those are left untouched here.
|
||||||
Future<void> _limpiarFalloProgramacion(String alarmaId) async {
|
Future<void> _limpiarFalloProgramacion(String alarmaId) async {
|
||||||
try {
|
try {
|
||||||
final config = await servicio.limpiarFalloProgramacion(alarmaId);
|
var config = await servicio.limpiarFalloProgramacion(
|
||||||
|
alarmaId,
|
||||||
|
ExcepcionAlarma.tipoFalloProgramacion,
|
||||||
|
);
|
||||||
|
_aplicar(config);
|
||||||
|
config = await servicio.limpiarFalloProgramacion(
|
||||||
|
alarmaId,
|
||||||
|
ExcepcionAlarma.tipoFalloReprogramacionArranque,
|
||||||
|
);
|
||||||
_aplicar(config);
|
_aplicar(config);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
|
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
|
||||||
@@ -525,6 +538,39 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drains the failures the NATIVE side recorded on its own and turns each
|
||||||
|
/// into a per-alarm exception, so the card can mark it.
|
||||||
|
///
|
||||||
|
/// These three paths used to log to logcat and stop there: a pre-notice
|
||||||
|
/// that could not be armed, a refused foreground-service start when the
|
||||||
|
/// alarm should have rung, and a per-alarm reschedule that failed after a
|
||||||
|
/// reboot. None of them run inside a Dart call, so nothing on this side
|
||||||
|
/// ever learned they happened — an alarm could sit switched on in the
|
||||||
|
/// list having never reached the OS. Reading them at startup is what
|
||||||
|
/// makes the reported "as if there were no alarm" visible.
|
||||||
|
///
|
||||||
|
/// Deliberately tolerant: a failed read is logged and swallowed, never
|
||||||
|
/// surfaced as an alarm error, because a diagnostics gap must not look
|
||||||
|
/// like a scheduling problem.
|
||||||
|
Future<void> cargarFallosNativos() async {
|
||||||
|
try {
|
||||||
|
final fallos = await android.fallosNativosProgramacion();
|
||||||
|
for (final fallo in fallos) {
|
||||||
|
final alarmaId = fallo['alarmaId'] as String?;
|
||||||
|
final tipo = fallo['tipo'] as String?;
|
||||||
|
if (alarmaId == null || tipo == null) continue;
|
||||||
|
await _registrarFalloProgramacion(alarmaId, tipo: tipo);
|
||||||
|
}
|
||||||
|
if (fallos.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Records a snooze the native layer performed by itself (Decision 2.1).
|
/// Records a snooze the native layer performed by itself (Decision 2.1).
|
||||||
/// The native scheduler already re-registered setAlarmClock, so this only
|
/// The native scheduler already re-registered setAlarmClock, so this only
|
||||||
/// persists the canonical state — it MUST NOT call android.programar again.
|
/// persists the canonical state — it MUST NOT call android.programar again.
|
||||||
@@ -624,6 +670,60 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
|
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
|
||||||
}
|
}
|
||||||
await _importarSnoozesNativosActivos();
|
await _importarSnoozesNativosActivos();
|
||||||
|
await _importarFallosProgramacionNativos();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports
|
||||||
|
/// scheduling-reliability failures the NATIVE side recorded on its own --
|
||||||
|
/// a pre-notice `SecurityException`, a refused foreground-service start,
|
||||||
|
/// or a per-alarm reschedule failure after boot/unlock -- none of which
|
||||||
|
/// ever go through a Dart method-channel call that could throw. Without
|
||||||
|
/// this sync, these three failures stayed invisible forever (only
|
||||||
|
/// logcat), even after this app-launch fix reads them.
|
||||||
|
Future<void> _importarFallosProgramacionNativos() async {
|
||||||
|
try {
|
||||||
|
final fallos = await android.obtenerFallosProgramacionNativos();
|
||||||
|
final reportadoPorAlarma = {
|
||||||
|
for (final fallo in fallos) fallo.alarmaId: fallo,
|
||||||
|
};
|
||||||
|
// Reconcile stale copies: the native side clears its OWN record the
|
||||||
|
// next time that specific subsystem succeeds (pre-notice/foreground-
|
||||||
|
// service), so an alarm previously imported with one of those tipos
|
||||||
|
// that is no longer reported here means it already recovered --
|
||||||
|
// without this, the card would keep showing a problem that fixed
|
||||||
|
// itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque`
|
||||||
|
// are NOT reconciled here -- those already clear on the Dart side's
|
||||||
|
// own successful `android.programar` calls.
|
||||||
|
for (final alarma in _alarmas) {
|
||||||
|
final actual = ultimaExcepcionPara(alarma.id);
|
||||||
|
final esTipoReconciliable =
|
||||||
|
actual != null &&
|
||||||
|
(actual.tipo == ExcepcionAlarma.tipoFalloPreaviso ||
|
||||||
|
actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido);
|
||||||
|
if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) {
|
||||||
|
final config = await servicio.limpiarFalloProgramacion(
|
||||||
|
alarma.id,
|
||||||
|
actual.tipo,
|
||||||
|
);
|
||||||
|
_aplicar(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final fallo in fallos) {
|
||||||
|
final config = await servicio.registrarFalloProgramacion(
|
||||||
|
fallo.alarmaId,
|
||||||
|
fallo.ocurridoEn,
|
||||||
|
fallo.tipo,
|
||||||
|
);
|
||||||
|
_aplicar(config);
|
||||||
|
}
|
||||||
|
if (fallos.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[PluriWave][alarmas] fallos nativos importados count=${fallos.length}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
|
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
|
||||||
|
|||||||
@@ -378,18 +378,28 @@ class ServicioAlarmas {
|
|||||||
return nuevo;
|
return nuevo;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Clears any outstanding failure record for [alarmaId] (a subsequent
|
/// Clears the outstanding failure record for [alarmaId] ONLY when its
|
||||||
/// scheduling attempt succeeded). No-op when there is nothing to clear.
|
/// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind
|
||||||
|
/// succeeded). Type-scoped on purpose: a successful main-alarm schedule
|
||||||
|
/// call proves nothing about the pre-notice or foreground-service
|
||||||
|
/// subsystems, so it must never clear a failure recorded for those. No-op
|
||||||
|
/// when there is nothing to clear or the recorded tipo does not match.
|
||||||
Future<ConfiguracionAlarmas> limpiarFalloProgramacion(
|
Future<ConfiguracionAlarmas> limpiarFalloProgramacion(
|
||||||
String alarmaId,
|
String alarmaId,
|
||||||
|
String tipo,
|
||||||
) => _enCola(() async {
|
) => _enCola(() async {
|
||||||
final config = await _configActual();
|
final config = await _configActual();
|
||||||
final sinFallo = _sinFalloPrevio(config.excepciones, alarmaId);
|
final actual = config.excepciones.where((e) => e.alarmaId == alarmaId);
|
||||||
if (sinFallo.length == config.excepciones.length) return config;
|
final tieneEseTipo = actual.any((e) => e.tipo == tipo);
|
||||||
|
if (!tieneEseTipo) return config;
|
||||||
|
final excepciones =
|
||||||
|
config.excepciones
|
||||||
|
.where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo))
|
||||||
|
.toList();
|
||||||
final nuevo = ConfiguracionAlarmas(
|
final nuevo = ConfiguracionAlarmas(
|
||||||
alarmas: config.alarmas,
|
alarmas: config.alarmas,
|
||||||
vacaciones: config.vacaciones,
|
vacaciones: config.vacaciones,
|
||||||
excepciones: sinFallo,
|
excepciones: excepciones,
|
||||||
);
|
);
|
||||||
await _guardar(nuevo);
|
await _guardar(nuevo);
|
||||||
return nuevo;
|
return nuevo;
|
||||||
@@ -398,7 +408,8 @@ class ServicioAlarmas {
|
|||||||
List<ExcepcionAlarma> _sinFalloPrevio(
|
List<ExcepcionAlarma> _sinFalloPrevio(
|
||||||
List<ExcepcionAlarma> excepciones,
|
List<ExcepcionAlarma> excepciones,
|
||||||
String alarmaId,
|
String alarmaId,
|
||||||
) => excepciones
|
) =>
|
||||||
|
excepciones
|
||||||
.where(
|
.where(
|
||||||
(e) =>
|
(e) =>
|
||||||
!(e.alarmaId == alarmaId &&
|
!(e.alarmaId == alarmaId &&
|
||||||
|
|||||||
@@ -161,6 +161,35 @@ class EjecucionAlarmaNativa {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A scheduling-reliability failure the NATIVE side recorded on its own
|
||||||
|
/// (fix/alarmas-fallos-silenciosos, item 2): the pre-notice reminder, the
|
||||||
|
/// ringing foreground service, and a post-boot/unlock reschedule can each
|
||||||
|
/// fail without ever going through a Dart method-channel call that could
|
||||||
|
/// throw -- the native scheduler persists these instead (mirroring how
|
||||||
|
/// handled occurrences and snooze state already survive a killed engine),
|
||||||
|
/// and this is the cold-start sync so the Dart side finds out at all.
|
||||||
|
class FalloProgramacionNativo {
|
||||||
|
const FalloProgramacionNativo({
|
||||||
|
required this.alarmaId,
|
||||||
|
required this.tipo,
|
||||||
|
required this.ocurridoEn,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String alarmaId;
|
||||||
|
final String tipo;
|
||||||
|
final DateTime ocurridoEn;
|
||||||
|
|
||||||
|
factory FalloProgramacionNativo.fromMap(Map<Object?, Object?> map) {
|
||||||
|
return FalloProgramacionNativo(
|
||||||
|
alarmaId: map['alarmId'] as String? ?? '',
|
||||||
|
tipo: map['type'] as String? ?? '',
|
||||||
|
ocurridoEn: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
(map['atMillis'] as num?)?.toInt() ?? 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract class PuertoAlarmasAndroid {
|
abstract class PuertoAlarmasAndroid {
|
||||||
Stream<EventoAlarmaAndroid> get eventosAlarma;
|
Stream<EventoAlarmaAndroid> get eventosAlarma;
|
||||||
|
|
||||||
@@ -170,6 +199,17 @@ abstract class PuertoAlarmasAndroid {
|
|||||||
|
|
||||||
Future<void> programar(AlarmaMusical alarma);
|
Future<void> programar(AlarmaMusical alarma);
|
||||||
Future<void> cancelar(String alarmaId);
|
Future<void> cancelar(String alarmaId);
|
||||||
|
|
||||||
|
/// Failures the NATIVE side recorded on its own, outside any Dart call:
|
||||||
|
/// a pre-notice that could not be armed, a refused foreground-service
|
||||||
|
/// start when the alarm should have rung, and a per-alarm reschedule that
|
||||||
|
/// failed after a reboot. Each entry carries the alarm id and one of
|
||||||
|
/// [ExcepcionAlarma]'s `tipoFallo*` constants.
|
||||||
|
///
|
||||||
|
/// Before this existed every one of those paths logged to logcat and
|
||||||
|
/// stopped there, so an alarm could sit switched on in the list having
|
||||||
|
/// never reached the OS at all — the user's "as if there were no alarm".
|
||||||
|
Future<List<Map<String, Object?>>> fallosNativosProgramacion();
|
||||||
Future<void> ocultarNotificacionAlarma(String alarmaId);
|
Future<void> ocultarNotificacionAlarma(String alarmaId);
|
||||||
|
|
||||||
/// Notification-only dismissal (RES-1): hides the fire notification for
|
/// Notification-only dismissal (RES-1): hides the fire notification for
|
||||||
@@ -203,6 +243,11 @@ abstract class PuertoAlarmasAndroid {
|
|||||||
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
|
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
|
||||||
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
|
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
|
||||||
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
|
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
|
||||||
|
|
||||||
|
/// Scheduling-reliability failures the native side recorded on its own
|
||||||
|
/// (pre-notice, foreground-service start, or post-boot reschedule) since
|
||||||
|
/// the last sync.
|
||||||
|
Future<List<FalloProgramacionNativo>> obtenerFallosProgramacionNativos();
|
||||||
}
|
}
|
||||||
|
|
||||||
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||||
@@ -393,6 +438,25 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, Object?>>> fallosNativosProgramacion() async {
|
||||||
|
try {
|
||||||
|
final raw = await _channel.invokeMethod<List<Object?>>(
|
||||||
|
'getNativeSchedulingFailures',
|
||||||
|
);
|
||||||
|
if (raw == null) return const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map<Object?, Object?>>()
|
||||||
|
.map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
// Never let a diagnostics read break alarm handling: an older build
|
||||||
|
// of the native side simply has no such channel method.
|
||||||
|
debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||||
final abierto = await _channel.invokeMethod<bool>(
|
final abierto = await _channel.invokeMethod<bool>(
|
||||||
@@ -494,6 +558,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<FalloProgramacionNativo>>
|
||||||
|
obtenerFallosProgramacionNativos() async {
|
||||||
|
final raw = await _channel.invokeMethod<List<Object?>>(
|
||||||
|
'getNativeSchedulingFailures',
|
||||||
|
);
|
||||||
|
if (raw == null || raw.isEmpty) return const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map<Object?, Object?>>()
|
||||||
|
.map(FalloProgramacionNativo.fromMap)
|
||||||
|
.where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
|
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
|
||||||
debugPrint('[PluriWave][alarmas] $method $args');
|
debugPrint('[PluriWave][alarmas] $method $args');
|
||||||
return _channel.invokeMethod<void>(method, args);
|
return _channel.invokeMethod<void>(method, args);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||||
|
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../helpers/fakes_alarmas.dart';
|
||||||
|
|
||||||
|
/// The native scheduler records three failures entirely on its own, outside
|
||||||
|
/// any Dart call: a pre-notice that could not be armed, a refused
|
||||||
|
/// foreground-service start when the alarm should have rung, and a per-alarm
|
||||||
|
/// reschedule that failed after a reboot.
|
||||||
|
///
|
||||||
|
/// Before this wiring existed, all three logged to logcat and stopped there.
|
||||||
|
/// The alarm stayed switched on in the list, drawn exactly as if scheduling
|
||||||
|
/// had succeeded, and simply never fired — the reported "as if there were no
|
||||||
|
/// alarm at all". These tests pin the drain path that makes them visible.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
late FakePuertoAlarmasAndroid android;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
android = FakePuertoAlarmasAndroid();
|
||||||
|
});
|
||||||
|
|
||||||
|
EstadoAlarmas crearEstado() =>
|
||||||
|
EstadoAlarmas(android: android, iniciarAutomaticamente: false);
|
||||||
|
|
||||||
|
test('a native pre-notice failure becomes a per-alarm exception', () async {
|
||||||
|
android.fallosNativos = [
|
||||||
|
{'alarmaId': 'a1', 'tipo': ExcepcionAlarma.tipoFalloPreaviso},
|
||||||
|
];
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await estado.cargarFallosNativos();
|
||||||
|
|
||||||
|
final fallo = estado.ultimaExcepcionPara('a1');
|
||||||
|
expect(fallo, isNotNull);
|
||||||
|
expect(fallo!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a refused foreground-service start becomes a per-alarm exception',
|
||||||
|
() async {
|
||||||
|
android.fallosNativos = [
|
||||||
|
{'alarmaId': 'a2', 'tipo': ExcepcionAlarma.tipoFalloServicioSonido},
|
||||||
|
];
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await estado.cargarFallosNativos();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
estado.ultimaExcepcionPara('a2')?.tipo,
|
||||||
|
ExcepcionAlarma.tipoFalloServicioSonido,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('only the alarm whose reschedule failed is marked', () async {
|
||||||
|
android.fallosNativos = [
|
||||||
|
{
|
||||||
|
'alarmaId': 'a3',
|
||||||
|
'tipo': ExcepcionAlarma.tipoFalloReprogramacionArranque,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await estado.cargarFallosNativos();
|
||||||
|
|
||||||
|
expect(estado.ultimaExcepcionPara('a3'), isNotNull);
|
||||||
|
expect(
|
||||||
|
estado.ultimaExcepcionPara('a4'),
|
||||||
|
isNull,
|
||||||
|
reason: 'a sibling alarm that scheduled fine must stay unmarked',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed native entries are skipped without throwing', () async {
|
||||||
|
android.fallosNativos = [
|
||||||
|
{'tipo': ExcepcionAlarma.tipoFalloPreaviso}, // no alarmaId
|
||||||
|
{'alarmaId': 'a5'}, // no tipo
|
||||||
|
];
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await estado.cargarFallosNativos();
|
||||||
|
|
||||||
|
expect(estado.ultimaExcepcionPara('a5'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failing native read never surfaces as an alarm error', () async {
|
||||||
|
// A diagnostics gap must not look like a scheduling problem: an older
|
||||||
|
// native build simply has no such channel method.
|
||||||
|
android.fallaLecturaFallosNativos = true;
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await estado.cargarFallosNativos();
|
||||||
|
|
||||||
|
expect(estado.error, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../helpers/fakes_alarmas.dart';
|
import '../helpers/fakes_alarmas.dart';
|
||||||
@@ -73,4 +74,66 @@ void main() {
|
|||||||
expect(estado.ultimaExcepcionPara('sana1'), isNull);
|
expect(estado.ultimaExcepcionPara('sana1'), isNull);
|
||||||
expect(estado.error, isNull);
|
expect(estado.error, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'inicializar importa los fallos nativos (pre-aviso, servicio en primer '
|
||||||
|
'plano, reprogramacion tras arranque) como excepciones por alarma',
|
||||||
|
() async {
|
||||||
|
final android =
|
||||||
|
FakePuertoAlarmasAndroid()
|
||||||
|
..fallosProgramacionNativos.addAll([
|
||||||
|
FalloProgramacionNativo(
|
||||||
|
alarmaId: 'con-preaviso-roto',
|
||||||
|
tipo: ExcepcionAlarma.tipoFalloPreaviso,
|
||||||
|
ocurridoEn: DateTime(2026, 5, 25, 7),
|
||||||
|
),
|
||||||
|
FalloProgramacionNativo(
|
||||||
|
alarmaId: 'servicio-rechazado',
|
||||||
|
tipo: ExcepcionAlarma.tipoFalloServicioSonido,
|
||||||
|
ocurridoEn: DateTime(2026, 5, 25, 7),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
final servicio = ServicioAlarmas(
|
||||||
|
reloj: () => DateTime(2026, 5, 25, 7, 30),
|
||||||
|
);
|
||||||
|
await servicio.guardarAlarma(
|
||||||
|
AlarmaMusical(
|
||||||
|
id: 'con-preaviso-roto',
|
||||||
|
nombre: 'Diaria',
|
||||||
|
hora: 7,
|
||||||
|
minuto: 30,
|
||||||
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
|
diasSemana: const [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await servicio.guardarAlarma(
|
||||||
|
AlarmaMusical(
|
||||||
|
id: 'servicio-rechazado',
|
||||||
|
nombre: 'Diaria',
|
||||||
|
hora: 8,
|
||||||
|
minuto: 0,
|
||||||
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
|
diasSemana: const [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final estado = EstadoAlarmas(
|
||||||
|
servicio: servicio,
|
||||||
|
android: android,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
addTearDown(android.dispose);
|
||||||
|
|
||||||
|
await estado.inicializar();
|
||||||
|
|
||||||
|
final falloPreaviso = estado.ultimaExcepcionPara('con-preaviso-roto');
|
||||||
|
expect(falloPreaviso, isNotNull);
|
||||||
|
expect(falloPreaviso!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||||
|
|
||||||
|
final falloServicio = estado.ultimaExcepcionPara('servicio-rechazado');
|
||||||
|
expect(falloServicio, isNotNull);
|
||||||
|
expect(falloServicio!.tipo, ExcepcionAlarma.tipoFalloServicioSonido);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
|||||||
final soloOcultadas = <String>[];
|
final soloOcultadas = <String>[];
|
||||||
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
|
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
|
||||||
final snoozesNativos = <EstadoSnoozeNativo>[];
|
final snoozesNativos = <EstadoSnoozeNativo>[];
|
||||||
|
final fallosProgramacionNativos = <FalloProgramacionNativo>[];
|
||||||
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
|
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
|
||||||
bool ignoraOptimizacionBateria = true;
|
bool ignoraOptimizacionBateria = true;
|
||||||
int solicitudesExencionBateria = 0;
|
int solicitudesExencionBateria = 0;
|
||||||
@@ -186,9 +187,33 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
|||||||
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async =>
|
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async =>
|
||||||
List.of(snoozesNativos);
|
List.of(snoozesNativos);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<FalloProgramacionNativo>>
|
||||||
|
obtenerFallosProgramacionNativos() async =>
|
||||||
|
List.of(fallosProgramacionNativos);
|
||||||
|
|
||||||
int solicitudesPermisoAlarmasExactas = 0;
|
int solicitudesPermisoAlarmasExactas = 0;
|
||||||
int solicitudesPermisoPantallaCompleta = 0;
|
int solicitudesPermisoPantallaCompleta = 0;
|
||||||
|
|
||||||
|
/// Native-recorded failures the next read should return. Tests seed this
|
||||||
|
/// to simulate a pre-notice that never armed, a refused foreground-service
|
||||||
|
/// start, or a per-alarm reschedule that failed after a reboot.
|
||||||
|
List<Map<String, Object?>> fallosNativos = const [];
|
||||||
|
|
||||||
|
int lecturasFallosNativos = 0;
|
||||||
|
|
||||||
|
/// Simulates an older native build with no such channel method.
|
||||||
|
bool fallaLecturaFallosNativos = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, Object?>>> fallosNativosProgramacion() async {
|
||||||
|
lecturasFallosNativos++;
|
||||||
|
if (fallaLecturaFallosNativos) {
|
||||||
|
throw StateError('canal no disponible');
|
||||||
|
}
|
||||||
|
return fallosNativos;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||||
solicitudesPermisoAlarmasExactas++;
|
solicitudesPermisoAlarmasExactas++;
|
||||||
|
|||||||
@@ -124,11 +124,42 @@ void main() {
|
|||||||
ExcepcionAlarma.tipoFalloProgramacion,
|
ExcepcionAlarma.tipoFalloProgramacion,
|
||||||
);
|
);
|
||||||
|
|
||||||
final config = await servicio.limpiarFalloProgramacion('a1');
|
final config = await servicio.limpiarFalloProgramacion(
|
||||||
|
'a1',
|
||||||
|
ExcepcionAlarma.tipoFalloProgramacion,
|
||||||
|
);
|
||||||
|
|
||||||
expect(config.excepciones, isEmpty);
|
expect(config.excepciones, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('limpiarFalloProgramacion NO limpia un fallo de un tipo distinto '
|
||||||
|
'(cada subsistema se limpia por su cuenta)', () async {
|
||||||
|
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||||
|
await servicio.guardarAlarma(
|
||||||
|
AlarmaMusical(
|
||||||
|
id: 'a1',
|
||||||
|
nombre: 'Diaria',
|
||||||
|
hora: 7,
|
||||||
|
minuto: 30,
|
||||||
|
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||||
|
diasSemana: const [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await servicio.registrarFalloProgramacion(
|
||||||
|
'a1',
|
||||||
|
DateTime(2026, 5, 25, 7, 30),
|
||||||
|
ExcepcionAlarma.tipoFalloPreaviso,
|
||||||
|
);
|
||||||
|
|
||||||
|
final config = await servicio.limpiarFalloProgramacion(
|
||||||
|
'a1',
|
||||||
|
ExcepcionAlarma.tipoFalloProgramacion,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.excepciones, hasLength(1));
|
||||||
|
expect(config.excepciones.single.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||||
|
});
|
||||||
|
|
||||||
test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste '
|
test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste '
|
||||||
'cambios', () async {
|
'cambios', () async {
|
||||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||||
@@ -143,7 +174,10 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final config = await servicio.limpiarFalloProgramacion('a1');
|
final config = await servicio.limpiarFalloProgramacion(
|
||||||
|
'a1',
|
||||||
|
ExcepcionAlarma.tipoFalloProgramacion,
|
||||||
|
);
|
||||||
|
|
||||||
expect(config.excepciones, isEmpty);
|
expect(config.excepciones, isEmpty);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user