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