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/grupo_favoritos.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/emisoras_destacadas.dart'; import 'package:pluriwave/servicios/navegacion_auto.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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, audio: FakeServicioAudio(ecualizadorDisponible: 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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 orden de listas — persistencia (WU6)', () { // Correction applied at apply time: cambiarOrdenListas'/_cargarOrdenListas // round-trip had zero test coverage before WU6 added OrdenEmisoras. // popularidad — a latent gap similar to WU5's androidAudioSessionIdStream // discovery. Adding a new enum case without covering the persistence // switch would have shipped a silent revert-to-calidad-on-restart bug. test( 'popularidad persiste y sobrevive a una nueva instancia (reinicio)', () async { final estadoUno = EstadoRadio( esPremium: () => true, audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estadoUno.inicializar(); await estadoUno.cambiarOrdenListas(OrdenEmisoras.popularidad); expect(estadoUno.ordenListas, OrdenEmisoras.popularidad); // Fresh instance, same (mocked) SharedPreferences-backed store — // simulates an app restart re-reading the persisted preference. final estadoDos = EstadoRadio( esPremium: () => true, audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estadoDos.inicializar(); expect(estadoDos.ordenListas, OrdenEmisoras.popularidad); }, ); }); 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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( esPremium: () => true, 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); }); }); group('EstadoRadio — Android Auto: snapshot en vivo y reconciliación ' '(android-auto-media)', () { test('empuja un snapshot actualizado a la fuente registrada cuando ' 'cambian favoritos/custom/populares', () async { final fuenteAuto = _FuenteEmisorasAutoEspia(); final archivo = await _crearArchivoCustom([ emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'), ]); final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto'); final estado = EstadoRadio( esPremium: () => true, audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio( populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')], ), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => archivo, fuenteAuto: fuenteAuto, iniciarAutomaticamente: false, ); await estado.inicializar(); expect( fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid), contains('custom-auto-1'), ); expect( fuenteAuto.ultimoTodas?.map((e) => e.uuid), contains('pop-auto-1'), ); expect( fuenteAuto.ultimoGrupos?.map((g) => g.id), contains(GrupoFavoritos.sinAsignarId), ); await estado.toggleFavorito(emisoraFav); expect( fuenteAuto.ultimoFavoritos?.map((e) => e.uuid), contains('fav-auto-1'), ); }); test( 'fix android-auto-orden: el snapshot de favoritos es EXACTAMENTE ' 'listaFavoritosManual (el orden manual que la pantalla Favoritos ' 'muestra y reordena), nunca la lista cruda ni un getter reordenado', () async { final favoritosServicio = FakeServicioFavoritos(); // Agregados en orden Z, A: si el push usara un getter reordenado // (p. ej. alfabético), 'alfa' iría primero. El orden manual conserva // el orden de inserción/persistencia: Z, A. await favoritosServicio.agregar( emisoraDemo(uuid: 'zulu', nombre: 'Zulu Fav'), ); await favoritosServicio.agregar( emisoraDemo(uuid: 'alfa', nombre: 'Alfa Fav'), ); final fuenteAuto = _FuenteEmisorasAutoEspia(); final estado = EstadoRadio( esPremium: () => true, audio: FakeServicioAudio(), favoritos: favoritosServicio, radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, fuenteAuto: fuenteAuto, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(fuenteAuto.ultimoFavoritos?.map((e) => e.uuid).toList(), [ 'zulu', 'alfa', ]); expect(fuenteAuto.ultimoFavoritos, equals(estado.listaFavoritosManual)); }, ); test('fix android-auto-orden: el snapshot de "Todas" honra el orden ' 'global (ordenListas) igual que "Tendencias" en el teléfono, no el ' 'orden crudo de llegada de la API', () async { SharedPreferences.setMockInitialValues({ 'orden_listas_emisoras_v1': 'nombre', }); final fuenteAuto = _FuenteEmisorasAutoEspia(); final estado = EstadoRadio( esPremium: () => true, audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio( populares: [ emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'), emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'), ], ), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, fuenteAuto: fuenteAuto, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [ 'alfa-pop', 'zulu-pop', ]); }); test('fix android-auto-orden: el snapshot de "Mis emisoras" honra el ' 'orden global (ordenListas), no el orden crudo del archivo', () async { SharedPreferences.setMockInitialValues({ 'orden_listas_emisoras_v1': 'nombre', }); final archivo = await _crearArchivoCustom([ emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'), emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'), ]); final fuenteAuto = _FuenteEmisorasAutoEspia(); final estado = EstadoRadio( esPremium: () => true, audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => archivo, fuenteAuto: fuenteAuto, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [ 'alfa-custom', 'zulu-custom', ]); }); test('fix android-auto-orden: cambiarOrdenListas re-empuja de inmediato ' 'los snapshots de "Todas" y "Mis emisoras" con el nuevo orden, sin ' 'esperar a la próxima recarga completa', () async { final archivo = await _crearArchivoCustom([ emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'), emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'), ]); final fuenteAuto = _FuenteEmisorasAutoEspia(); final estado = EstadoRadio( esPremium: () => true, audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio( populares: [ emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'), emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'), ], ), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: () async => archivo, fuenteAuto: fuenteAuto, iniciarAutomaticamente: false, ); await estado.inicializar(); await estado.cambiarOrdenListas(OrdenEmisoras.nombre); expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [ 'alfa-pop', 'zulu-pop', ]); expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [ 'alfa-custom', 'zulu-custom', ]); }); test('reconcilia _emisoraSeleccionada cuando la selección viene desde ' 'el auto (no via reproducir())', () async { final audio = _AudioControlado(); final estado = EstadoRadio( esPremium: () => true, audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); final desdeCoche = emisoraDemo( uuid: 'auto-selected', nombre: 'Desde el auto', ); audio.seleccionarDesdeAuto(desdeCoche); await Future.delayed(Duration.zero); expect(estado.emisoraActual?.uuid, desdeCoche.uuid); }); }); group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () { test('la última emisora reproducida sobrevive a una nueva instancia ' '(reinicio) como emisoraActual DETENIDA -- no arranca audio, no ' 'reproduce, sólo queda seleccionada', () async { final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM'); // The record is now written by the audio handler's `_cambiarFuente` // (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of // `ultima_emisora_v1` and the only one that also exists on the headless // Android Auto engine — `EstadoRadio` used to write it too and no // longer does. Seeded through that same production function here, so // this test covers what `EstadoRadio` actually owns (the RESTORE) with // a real payload instead of one a fake invented. The write itself is // covered end to end in // `test/servicios/servicio_audio_ultima_emisora_test.dart`. await guardarUltimaEmisoraPersistida(emisora); final audioDos = FakeServicioAudio(); final estadoDos = EstadoRadio( esPremium: () => true, audio: audioDos, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estadoDos.inicializar(); expect(estadoDos.emisoraActual?.uuid, emisora.uuid); expect(estadoDos.emisoraActual?.nombre, emisora.nombre); expect( audioDos.estaSonando, isFalse, reason: 'restoring the last station must never auto-start audio', ); }); test('sin ninguna emisora previamente reproducida, emisoraActual sigue ' 'siendo null tras inicializar (instalación nueva)', () async { final estado = EstadoRadio( esPremium: () => true, audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); expect(estado.emisoraActual, isNull); }); test('una emisora seleccionada desde el auto (fuera de reproducir()) ' 'deja de estar ensombrecida por la seleccion previa del telefono', () async { // The PERSISTENCE half of this scenario moved to the handler, which is // the only writer that exists on a car-only session — it is covered by // «playFromMediaId desde el coche persiste ESA emisora» in // `test/servicios/servicio_audio_ultima_emisora_test.dart`. What // `EstadoRadio` still owns here, and what this test now pins, is the // shadowing fix: a car selection bypasses `reproducir()`, so without // the `estadoStream` listener `_emisoraSeleccionada` would keep // shadowing the car's station on the `emisoraActual` getter. final audio = _AudioControlado(); final estado = EstadoRadio( esPremium: () => true, audio: audio, favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); await estado.inicializar(); final desdeElTelefono = emisoraDemo( uuid: 'phone-picked', nombre: 'Elegida en el telefono', ); unawaited(estado.reproducir(desdeElTelefono)); audio.completar(desdeElTelefono.uuid); await Future.delayed(Duration.zero); expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid); final desdeCoche = emisoraDemo( uuid: 'auto-remembered', nombre: 'Recordada desde el auto', ); audio.seleccionarDesdeAuto(desdeCoche); await Future.delayed(Duration.zero); expect( estado.emisoraActual?.uuid, desdeCoche.uuid, reason: 'the car changed the station without going through reproducir(); ' 'the phone UI must follow it instead of keeping the previous ' 'selection on screen', ); }); }); } /// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by /// [EstadoRadio] via [actualizarSnapshot] — used to assert the live-snapshot /// wiring (Design "live snapshot the source prefers") without touching the /// real local data source. class _FuenteEmisorasAutoEspia implements FuenteEmisorasAuto { List? ultimoFavoritos; List? ultimoMisEmisoras; List? ultimoTodas; List? ultimoGrupos; void actualizarSnapshot({ List? favoritos, List? misEmisoras, List? todas, List? grupos, }) { if (favoritos != null) ultimoFavoritos = favoritos; if (misEmisoras != null) ultimoMisEmisoras = misEmisoras; if (todas != null) ultimoTodas = todas; if (grupos != null) ultimoGrupos = grupos; } @override Future> favoritos() async => ultimoFavoritos ?? const []; @override Future> misEmisoras() async => ultimoMisEmisoras ?? const []; @override Future> todas() async => ultimoTodas ?? const []; @override Future> grupos() async => ultimoGrupos ?? const []; @override Future porUuid(String uuid) async => null; } /// [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(); } /// Simulates a car-initiated selection (android-auto-media task 4.3): /// changes [emisoraActual] WITHOUT going through [reproducir], then pushes /// an `estadoStream` event, exactly like the real handler would after /// `playFromMediaId` bypasses `EstadoRadio.reproducir()`. void seleccionarDesdeAuto(Emisora emisora) { _actual = emisora; _estadoController.add(EstadoReproduccion.reproduciendo); } @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; }