import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { SharedPreferences.setMockInitialValues({}); }); group('EstadoRadio integración de custom + EQ persistente', () { test('incluye emisoras custom en el listado principal de inicio', () async { final archivo = await _crearArchivoCustom([ emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'), ]); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio( populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')], ), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => archivo, iniciarAutomaticamente: false, ); await estado.inicializar(); expect( estado.emisorasInicio.map((e) => e.uuid).toList(), equals(['custom-1', 'api-1']), ); }); test( 'carga EQ principal persistido antes de decidir EQ de reproducción', () async { final audio = FakeServicioAudio(); final principal = PresetEcualizador.rock; final emisora = emisoraDemo(uuid: 'api-1', nombre: 'API Uno'); final estado = EstadoRadio( audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(populares: [emisora]), servicioEcualizador: FakeServicioEcualizador(principal: principal), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); await estado.reproducir(emisora); expect(estado.ecualizador.presetActual, principal); expect(audio.presetsAplicados.first, principal); expect(audio.presetsAplicados.last, principal); }, ); test( 'mantiene EQ persistido aunque el ecualizador nativo no esté disponible', () async { final principal = PresetEcualizador.jazz; final porEmisora = {'fav-1': PresetEcualizador.rock}; final estado = EstadoRadio( audio: FakeServicioAudio(ecualizadorActivo: false), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador( principal: principal, porEmisora: porEmisora, ), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.ecualizador.disponible, isFalse); expect(estado.ecualizador.presetActual, principal); expect(estado.ecualizador.presetPrincipal, principal); expect( estado.ecualizador.presetPorEmisora('fav-1'), PresetEcualizador.rock, ); }, ); test( 'inicializar deja error tras fallo y cargarPopulares manual recupera estaciones', () async { final radio = FakeServicioRadio( erroresPopularesPorLlamada: [Exception('sin red')], popularesPorLlamada: [ const [], [emisoraDemo(uuid: 'api-ok', nombre: 'API Recuperada')], ], tendenciasPorLlamada: [ const [], [emisoraDemo(uuid: 'trend-ok', nombre: 'Trend Recuperada')], ], ); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: radio, servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.error, 'Sin conexión a la API de radio'); await estado.cargarPopulares(); expect(estado.error, isNull); expect(estado.populares.map((e) => e.uuid), contains('api-ok')); expect(estado.tendencias.map((e) => e.uuid), contains('trend-ok')); expect(radio.obtenerPopularesCalls, 2); }, ); test( 'EQ propio por emisora pisa al principal y puede volver a fallback', () async { final audio = FakeServicioAudio(); final favoritos = FakeServicioFavoritos(); final emisora = emisoraDemo(uuid: 'fav-1', nombre: 'Favorita'); final principal = PresetEcualizador.pop; final propio = PresetEcualizador.jazz; await favoritos.agregar(emisora); final estado = EstadoRadio( audio: audio, favoritos: favoritos, radio: FakeServicioRadio(populares: [emisora]), servicioEcualizador: FakeServicioEcualizador(principal: principal), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); await estado.cargarFavoritos(); await estado.ecualizador.guardarPresetPorEmisora(emisora.uuid, propio); await estado.reproducir(emisora); expect(estado.ecualizador.presetActual, propio); expect(audio.presetsAplicados.last, propio); await estado.ecualizador.deshabilitarPresetPorEmisora(emisora.uuid); await estado.reproducir(emisora); expect(estado.ecualizador.presetActual, principal); expect(audio.presetsAplicados.last, principal); }, ); test( 'favorita sin EQ propio usa EQ principal desde el primer play', () async { final audio = FakeServicioAudio(); final favoritos = FakeServicioFavoritos(); final emisora = emisoraDemo(uuid: 'fav-main', nombre: 'Favorita Main'); final principal = PresetEcualizador.voz; await favoritos.agregar(emisora); final estado = EstadoRadio( audio: audio, favoritos: favoritos, radio: FakeServicioRadio(populares: [emisora]), servicioEcualizador: FakeServicioEcualizador(principal: principal), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); await estado.cargarFavoritos(); await estado.reproducir(emisora); expect(estado.ecualizador.tienePresetPorEmisora(emisora.uuid), isFalse); expect(estado.ecualizador.presetActual, principal); expect(audio.presetsAplicados.last, principal); }, ); test( 'permite activar y desactivar el ecualizador de forma persistente', () async { final audio = FakeServicioAudio(); final servicioEcualizador = FakeServicioEcualizador(); final estado = EstadoRadio( audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: servicioEcualizador, resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.ecualizador.activo, isTrue); await estado.ecualizador.cambiarActivo(false); expect(estado.ecualizador.activo, isFalse); expect(servicioEcualizador.config.activo, isFalse); expect(audio.cambiosEcualizadorActivo.last, isFalse); await estado.ecualizador.cambiarActivo(true); expect(estado.ecualizador.activo, isTrue); expect(servicioEcualizador.config.activo, isTrue); expect(audio.cambiosEcualizadorActivo.last, isTrue); }, ); test( 'notifica cambios de estado de audio para mostrar reproductor al primer play', () async { final audio = FakeServicioAudio(); final emisora = emisoraDemo(uuid: 'play-1', nombre: 'Primera'); final estado = EstadoRadio( audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(populares: [emisora]), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); var notificaciones = 0; estado.addListener(() => notificaciones++); await estado.inicializar(); final antes = notificaciones; audio.emitirEstado(EstadoReproduccion.cargando); await Future.delayed(Duration.zero); expect(notificaciones, greaterThan(antes)); }, ); test( 'reproducir la misma emisora mientras suena fuerza recarga del stream', () async { final audio = FakeServicioAudio(); final emisora = emisoraDemo(uuid: 'same-1', nombre: 'Misma'); final estado = EstadoRadio( audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(populares: [emisora]), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); await estado.reproducir(emisora); await estado.reproducir(emisora); expect(audio.emisorasReproducidas, hasLength(2)); }, ); test( 'ignora finalizaciones stale cuando se cambia de emisora rapido', () async { final audio = _AudioControlado(); final radio = FakeServicioRadio(); final primera = emisoraDemo(uuid: 'slow-1', nombre: 'Lenta'); final segunda = emisoraDemo(uuid: 'fast-2', nombre: 'Rapida'); final estado = EstadoRadio( audio: audio, favoritos: FakeServicioFavoritos(), radio: radio, servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); await estado.ecualizador.guardarPresetPorEmisora( primera.uuid, PresetEcualizador.rock, ); await estado.ecualizador.guardarPresetPorEmisora( segunda.uuid, PresetEcualizador.jazz, ); final primeraFuture = estado.reproducir(primera); final segundaFuture = estado.reproducir(segunda); audio.completar(segunda.uuid); await segundaFuture; audio.completar(primera.uuid); await primeraFuture; expect(estado.ecualizador.presetActual, PresetEcualizador.jazz); expect(radio.ultimoUuidClick, segunda.uuid); }, ); test('reordenar favoritos reindexa de forma determinística', () async { final favoritos = FakeServicioFavoritos(); await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A')); await favoritos.agregar(emisoraDemo(uuid: 'b', nombre: 'B')); await favoritos.agregar(emisoraDemo(uuid: 'c', nombre: 'C')); await favoritos.reordenar('a', 2); final lista = await favoritos.obtenerTodos(); expect(lista.map((e) => e.uuid).toList(), equals(['b', 'c', 'a'])); expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2])); }); // The search pagination test moved to test/estado/estado_busqueda_test.dart // (S4-R3: search state extracted to EstadoBusqueda). test('toggleFavorito refresca lista global y evita estado stale', () async { final favoritos = FakeServicioFavoritos(); final emisora = emisoraDemo(uuid: 'fav-sync', nombre: 'Sync'); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: favoritos, radio: FakeServicioRadio(populares: [emisora]), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse); await estado.toggleFavorito(emisora); expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isTrue); await estado.toggleFavorito(emisora); expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse); }); test('crea y asigna grupos de favoritos sin tocar Sin asignar', () async { final favoritos = FakeServicioFavoritos(); final emisora = emisoraDemo(uuid: 'fav-group', nombre: 'Grupo'); await favoritos.agregar(emisora); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: favoritos, radio: FakeServicioRadio(populares: [emisora]), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.gruposFavoritos.first.protegido, isTrue); await estado.crearGrupoFavoritos('Noticias'); final grupo = estado.gruposFavoritos.last; await estado.asignarGrupoFavorito(emisora.uuid, grupo.id); expect(estado.listaFavoritos.first.grupoFavoritosId, grupo.id); await estado.eliminarGrupoFavoritos(grupo.id); expect(estado.listaFavoritos.first.grupoFavoritosId, 'sin_asignar'); }); }); group( 'EstadoRadio — emisoras custom: lectura tolerante y guardia de ' 'degradacion (persistence-resilience)', () { test( 'entradas invalidas se omiten sin perder las validas ni fabricar ' 'uuid (D5 parcial)', () async { final archivo = await _crearArchivoCustomRaw( jsonEncode([ {'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'}, {'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'}, // falta 'url' (campo requerido) -> Emisora.fromMap lanza. {'uuid': 'custom-3', 'nombre': 'Sin url'}, // falta 'uuid' -> Emisora.fromMap lanza. {'nombre': 'Sin uuid', 'url': 'http://d'}, ]), ); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => archivo, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.emisorasCustom, hasLength(2)); expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), { 'custom-1', 'custom-2', }); }, ); test( 'si resolver la ruta del archivo custom falla, la inicializacion ' 'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async { final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => throw const FileSystemException('sin storage'), iniciarAutomaticamente: false, ); // Path resolution failing must be treated as an IO-fail, not // escape _cargarEmisorasCustom: it runs inside _init()'s // Future.wait, so an uncaught throw would also reject the // sibling loads (populares/favoritos/grupos). await estado.inicializar(); expect(estado.emisorasCustom, isEmpty); }, ); test( 'JSON invalido al nivel superior pone en cuarentena el archivo ' 'original (D5 parse-fail)', () async { final archivo = await _crearArchivoCustomRaw('{bad'); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => archivo, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.emisorasCustom, isEmpty); final sidecar = File('${archivo.path}.corrupt'); expect(await sidecar.exists(), isTrue); expect(await sidecar.readAsString(), '{bad'); expect(await archivo.exists(), isFalse); }, ); test( 'agregar tras la cuarentena escribe solo la nueva emisora y no ' 'toca el sidecar (D5, autoridad de escritura restaurada)', () async { final archivo = await _crearArchivoCustomRaw('{bad'); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => archivo, iniciarAutomaticamente: false, ); await estado.inicializar(); final sidecar = File('${archivo.path}.corrupt'); final sidecarPrevio = await sidecar.readAsString(); final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva'); await estado.agregarEmisoraCustom(nueva); expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']); final contenidoVivo = jsonDecode(await archivo.readAsString()) as List; expect(contenidoVivo, hasLength(1)); expect((contenidoVivo.single as Map)['uuid'], 'nueva-1'); expect(await sidecar.readAsString(), sidecarPrevio); }, ); test( 'fallo de IO al leer suprime la escritura y no se restaura con un ' 'alta explicita (D5 IO-fail)', () async { final espia = _ArchivoEspia( path: '/fake/emisoras_custom.json', exists: () async => true, readAsString: () async => throw const FileSystemException( 'fallo simulado de lectura', ), ); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => espia, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.emisorasCustom, isEmpty); await estado.agregarEmisoraCustom( emisoraDemo(uuid: 'nueva-x', nombre: 'X'), ); expect( estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x'), ); expect(espia.writeAsStringCalls, 0); }, ); test( 'si ya existe un sidecar .corrupt no lo pisa, solo limpia el ' 'archivo vivo (D5)', () async { final archivo = await _crearArchivoCustomRaw('{bad-nuevo'); final sidecar = File('${archivo.path}.corrupt'); await sidecar.writeAsString('contenido-previo-X'); final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => archivo, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(await sidecar.readAsString(), 'contenido-previo-X'); expect(await archivo.exists(), isFalse); expect(estado.emisorasCustom, isEmpty); }, ); }, ); } /// [File] spy: only the members `_cargarEmisorasCustom`/ /// `_guardarEmisorasCustom` touch are implemented (with injectable /// overrides); everything else throws via noSuchMethod. Mirrors /// servicio_alarmas_corrupcion_test.dart's `_PrefsEspia` pattern, adapted /// to force an IO failure independent of the host OS (persistence- /// resilience D5). class _ArchivoEspia implements File { _ArchivoEspia({ required String path, Future Function()? exists, Future Function()? readAsString, Future Function(String contenido)? writeAsString, Future Function(String nuevoPath)? rename, }) : _path = path, _existsImpl = exists, _readAsStringImpl = readAsString, _writeAsStringImpl = writeAsString, _renameImpl = rename; final String _path; final Future Function()? _existsImpl; final Future Function()? _readAsStringImpl; final Future Function(String contenido)? _writeAsStringImpl; final Future Function(String nuevoPath)? _renameImpl; int writeAsStringCalls = 0; @override String get path => _path; @override Future exists() => (_existsImpl ?? () async => true)(); @override Future readAsString({Encoding encoding = utf8}) => (_readAsStringImpl ?? () async => '')(); @override Future writeAsString( String contents, { FileMode mode = FileMode.write, Encoding encoding = utf8, bool flush = false, }) { writeAsStringCalls++; return (_writeAsStringImpl ?? (_) async => this)(contents); } @override Future rename(String newPath) => (_renameImpl ?? (_) async => this)(newPath); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _AudioControlado extends ServicioAudio { final _estadoController = StreamController.broadcast(); final _pendientes = >{}; Emisora? _actual; @override Emisora? get emisoraActual => _actual; @override Stream get estadoStream => _estadoController.stream; @override Future reproducir(Emisora emisora) async { final completer = Completer(); _pendientes[emisora.uuid] = completer; _actual = emisora; _estadoController.add(EstadoReproduccion.cargando); await completer.future; _estadoController.add(EstadoReproduccion.reproduciendo); } void completar(String uuid) { _pendientes.remove(uuid)?.complete(); } @override Future aplicarPreset(PresetEcualizador preset) async {} @override Future dispose() async { await _estadoController.close(); } } Future _archivoCustomVacio() async => _crearArchivoCustom(const []); Future _crearArchivoCustom(List emisoras) async { final dir = await Directory.systemTemp.createTemp('pluriwave-test-'); final archivo = File('${dir.path}/emisoras_custom.json'); await archivo.writeAsString( jsonEncode(emisoras.map((e) => e.toMap()).toList()), ); return archivo; } /// Writes RAW (already-encoded) [contenido] to a fresh temp /// `emisoras_custom.json` -- unlike [_crearArchivoCustom], this bypasses /// `Emisora.toMap` so tests can seed malformed/partially-invalid JSON that /// a real Emisora could never produce (persistence-resilience D5). Future _crearArchivoCustomRaw(String contenido) async { final dir = await Directory.systemTemp.createTemp('pluriwave-test-'); final archivo = File('${dir.path}/emisoras_custom.json'); await archivo.writeAsString(contenido); return archivo; }