diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 9272961..10e9d8f 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -945,7 +945,12 @@ class EstadoRadio extends ChangeNotifier { final alarmasData = data['alarmas']; if (alarmasData is Map) { // Escribimos el bloque JSON tal como estaba en el dispositivo origen. - // ServicioAlarmas lo leerá con su propio fromJson al siguiente acceso. + // EstadoAlarmas es un ChangeNotifier independiente y de larga vida + // que ya cargó sus alarmas en memoria: NO relee este storage por sí + // solo. El llamador (pantalla_ajustes_backup.dart) es responsable de + // invocar `EstadoAlarmas.cargarPersistidasSinRecalcular()` seguido + // de `refrescarProgramacion()` tras un import exitoso; EstadoRadio + // se mantiene deliberadamente sin depender de EstadoAlarmas. await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData)); } } diff --git a/lib/pantallas/ajustes/pantalla_ajustes_backup.dart b/lib/pantallas/ajustes/pantalla_ajustes_backup.dart index c78be90..60cbd10 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_backup.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_backup.dart @@ -6,12 +6,45 @@ import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart' show Share, XFile; +import '../../estado/estado_alarmas.dart'; import '../../estado/estado_radio.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../widgets/pluri_glass_surface.dart'; import '../../widgets/pluri_layout.dart'; import '../../widgets/pluri_push_scaffold.dart'; +/// Applies a successfully-parsed backup to BOTH independent notifiers that +/// own pieces of it (fix/import-alarmas-y-paywall). +/// +/// `EstadoRadio.importarConfig` writes the raw alarm/vacation/exception JSON +/// block straight to SharedPreferences, but `EstadoAlarmas` is a separate +/// long-lived `ChangeNotifier` that loaded its alarms into memory at +/// construction and never re-reads storage on its own — `EstadoRadio` stays +/// deliberately free of a dependency on it. Without the two calls below the +/// imported block is invisible to the running app: the UI keeps showing the +/// pre-import alarms, a later edit would persist that stale in-memory list +/// OVER the imported one, and the imported alarms would never be +/// (re)scheduled with the Android native layer even after a restart. +/// +/// Extracted as a top-level function (rather than inlined in `_importar`) +/// so this exact production sequence — not a reimplementation of it — is +/// directly unit-testable without depending on the `file_picker` platform +/// channel or the confirmation dialog. +Future aplicarImportacionConfig( + EstadoRadio estado, + EstadoAlarmas alarmas, + Map json, +) async { + await estado.importarConfig(json); + // Re-reads from storage — clears ServicioAlarmas' in-memory cache so the + // just-imported alarms/vacations/exceptions (same JSON block, same + // notifier) replace the stale ones. + await alarmas.cargarPersistidasSinRecalcular(); + // Recomputes next-run times against the (now fresh) imported data and + // re-syncs every alarm with the Android native scheduler. + await alarmas.refrescarProgramacion(); +} + /// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved /// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` — /// only the panel header's icon and title were removed (the pushed screen's @@ -102,8 +135,9 @@ class _CuerpoBackup extends StatelessWidget { if (confirmar != true) return; if (context.mounted) { final estado = context.read(); + final alarmas = context.read(); final messenger = ScaffoldMessenger.of(context); - await estado.importarConfig(json); + await aplicarImportacionConfig(estado, alarmas, json); messenger.showSnackBar( SnackBar(content: Text(l10n.backupImportSuccess)), ); diff --git a/test/estado/estado_alarmas_import_test.dart b/test/estado/estado_alarmas_import_test.dart new file mode 100644 index 0000000..85d1d10 --- /dev/null +++ b/test/estado/estado_alarmas_import_test.dart @@ -0,0 +1,284 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_backup.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Regression coverage for the data-loss bug (fix/import-alarmas-y-paywall): +/// `EstadoRadio.importarConfig` writes the imported alarm block straight to +/// SharedPreferences, but `EstadoAlarmas` is a separate long-lived +/// `ChangeNotifier` that loaded its alarms into memory at construction and +/// never re-reads on its own. These tests exercise the EXACT sequence the +/// real call site (`pantalla_ajustes_backup.dart`'s `_importar`) now runs +/// after a successful import: `EstadoRadio.importarConfig` followed by +/// `EstadoAlarmas.cargarPersistidasSinRecalcular()` + +/// `EstadoAlarmas.refrescarProgramacion()` — bypassing the file_picker +/// platform channel and the confirmation dialog, which are pure UI +/// plumbing already covered by `pantalla_ajustes_backup_test.dart`. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + // A PRIVATE per-test file, never the shared `test/fixtures/` one: + // `EstadoRadio.importarConfig` unconditionally calls + // `_guardarEmisorasCustom()`, which WRITES to whatever + // `resolverArchivoCustom` resolves to — pointing that at the shared + // fixture previously clobbered its committed BOM on disk as a side + // effect of running this file's tests. + tempDir = await Directory.systemTemp.createTemp( + 'pluriwave_estado_alarmas_import_test', + ); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + Future archivoCustomVacio() async { + final file = File('${tempDir.path}/emisoras_custom.json'); + if (!file.existsSync()) { + await file.writeAsString('[]'); + } + return file; + } + + Map jsonAlarma(AlarmaMusical a) => { + 'id': a.id, + 'nombre': a.nombre, + 'activa': a.activa, + 'hora': a.hora, + 'minuto': a.minuto, + 'tipoProgramacion': a.tipoProgramacion.name, + 'diasSemana': a.diasSemana, + }; + + const alarmaVieja = AlarmaMusical( + id: 'vieja', + nombre: 'Alarma vieja (pre-import)', + hora: 6, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [1, 2, 3, 4, 5], + ); + + const alarmaImportada = AlarmaMusical( + id: 'importada', + nombre: 'Alarma importada', + hora: 8, + minuto: 15, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [6, 7], + ); + + /// Builds the pair the app wires together: `EstadoRadio` (owns + /// `importarConfig`) and `EstadoAlarmas` (owns the alarm reload + + /// re-scheduling this bugfix adds), sharing ONE `SharedPreferences` + /// instance exactly like the real app's provider tree does. + Future< + ({ + EstadoRadio radio, + EstadoAlarmas alarmas, + FakePuertoAlarmasAndroid android, + }) + > + crearPar() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'alarmas_musicales_v1', + jsonEncode({ + 'alarmas': [jsonAlarma(alarmaVieja)], + 'vacaciones': [], + 'excepciones': [], + }), + ); + final android = FakePuertoAlarmasAndroid(); + final alarmas = EstadoAlarmas( + esPremium: () => true, + servicio: ServicioAlarmas(prefs: prefs), + android: android, + prefs: prefs, + iniciarAutomaticamente: false, + ); + // Loads + native-syncs the pre-import alarm WITHOUT arming + // `inicializar()`'s periodic timers (irrelevant to this bugfix and a + // needless liability for a `flutter test` run). + await alarmas.refrescarProgramacion(); + + final radio = EstadoRadio( + esPremium: () => true, + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + prefs: prefs, + iniciarAutomaticamente: false, + ); + + return (radio: radio, alarmas: alarmas, android: android); + } + + Map backupCon({ + required List alarmas, + List> vacaciones = const [], + List> excepciones = const [], + String ordenListas = 'nombre', + }) => { + 'version': 2, + 'gruposFavoritos': [], + 'favoritos': [], + 'emisorasCustom': [], + 'presetsEcualizador': {}, + 'alarmas': { + 'alarmas': alarmas.map(jsonAlarma).toList(), + 'vacaciones': vacaciones, + 'excepciones': excepciones, + }, + 'emisoraPreferidaUuid': null, + 'ordenListas': ordenListas, + 'timerSuenoPresetsSegundos': [300, 600], + }; + + test('after import, EstadoAlarmas reflects the imported alarms, not the ' + 'pre-import ones', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']); + + await aplicarImportacionConfig( + par.radio, + par.alarmas, + backupCon(alarmas: [alarmaImportada]), + ); + + expect(par.alarmas.alarmas.map((a) => a.id), ['importada']); + expect(par.alarmas.alarmas.single.nombre, 'Alarma importada'); + }); + + test('native re-scheduling is triggered after an import', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + // Sanity: the pre-import alarm was already scheduled. + expect(par.android.programadas.map((a) => a.id), contains('vieja')); + + await aplicarImportacionConfig( + par.radio, + par.alarmas, + backupCon(alarmas: [alarmaImportada]), + ); + + // The imported alarm was handed to the native Android bridge — this is + // what makes it actually ring, not just appear in the list. + expect(par.android.programadas.map((a) => a.id), contains('importada')); + }); + + test( + 'vacation ranges and alarm exceptions in the same block come back too', + () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + expect(par.alarmas.vacaciones, isEmpty); + expect(par.alarmas.excepciones, isEmpty); + + await aplicarImportacionConfig( + par.radio, + par.alarmas, + backupCon( + alarmas: [alarmaImportada], + vacaciones: [ + { + 'id': 'vac1', + 'nombre': 'Verano', + 'inicio': '2026-07-01T00:00:00.000', + 'fin': '2026-07-15T00:00:00.000', + 'activo': true, + }, + ], + excepciones: [ + { + 'alarmaId': 'importada', + 'ejecucion': '2026-08-30T08:15:00.000', + 'tipo': 'skipNext', + }, + ], + ), + ); + + expect(par.alarmas.vacaciones.map((v) => v.id), ['vac1']); + expect(par.alarmas.excepciones.map((e) => e.alarmaId), ['importada']); + }, + ); + + test('a failed import (e.g. malformed/unsupported version) leaves existing ' + 'alarms untouched', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + final backupNoSoportado = backupCon(alarmas: [alarmaImportada]) + ..['version'] = 99; + + // Runs the SAME production function the call site uses: a throw from + // `importarConfig` must propagate before either reload call runs. + await expectLater( + aplicarImportacionConfig(par.radio, par.alarmas, backupNoSoportado), + throwsA(anything), + ); + + expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']); + expect(par.android.programadas.map((a) => a.id), ['vieja']); + }); + + test('a cancelled import (dialog declined, importarConfig never called) ' + 'leaves existing alarms untouched', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + // Simulates the user declining the confirm dialog: the call site + // returns before `importarConfig` and the two reload calls ever run. + expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']); + expect(par.android.programadas.map((a) => a.id), ['vieja']); + }); + + test('regression: importing still restores preferences (ordenListas) ' + 'exactly as before', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + await aplicarImportacionConfig( + par.radio, + par.alarmas, + backupCon(alarmas: [alarmaImportada], ordenListas: 'nombre'), + ); + + expect(par.radio.ordenListas.name, 'nombre'); + }); +}