import 'dart:async'; import 'dart:ui' show Locale; import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:just_audio/just_audio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/servicios/contexto_reproduccion.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/handlers_audio.dart'; /// Contexto de reproducción en el HANDLER — «al conectar el coche, siguiente/ /// anterior ya no recuerdan en qué lista estaba». /// /// El handler es el único que existe en los dos motores (el del móvil y el /// headless que levanta Android Auto sin Activity), así que es él quien tiene /// que escribir y leer el contexto. Lo hace por un PUERTO inyectado en /// `registrarHandler`, exactamente igual que el flag on/off del ecualizador: /// `servicio_audio.dart` no importa `shared_preferences` ni conoce /// `EstadoRadio`, que en el coche no llega a construirse nunca. void main() { TestWidgetsFlutterBinding.ensureInitialized(); final crearHandler = registrarHandlersLiberables(); late _GuionReproductor guion; late _PuertoContexto puerto; setUp(() { guion = _GuionReproductor(); puerto = _PuertoContexto(); PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) => _ReproductorFalso(guion, pipeline, carga); SharedPreferences.setMockInitialValues({}); }); tearDown(() { PluriWaveAudioHandler.fabricaReproductorPrueba = null; // `_fuenteNavegacionGlobal` es global de módulo: se deja siempre en una // fuente vacía para que un test no herede las listas del anterior. registrarFuenteNavegacion(_FuenteFalsa()); }); AppLocalizations textos() => lookupAppLocalizations(const Locale('es')); Emisora favorita(String uuid, String grupo) => Emisora( uuid: uuid, nombre: uuid, url: 'https://example.com/$uuid', grupoFavoritosId: grupo, ); final rock1 = favorita('rock1', 'g-rock'); final rock2 = favorita('rock2', 'g-rock'); final rock3 = favorita('rock3', 'g-rock'); final jazz1 = favorita('jazz1', 'g-jazz'); final jazz2 = favorita('jazz2', 'g-jazz'); const gruposVivos = [ GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1), GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), ]; PluriWaveAudioHandler handlerConPuerto() { final handler = crearHandler(); registrarHandler( handler, leerContextoSalto: puerto.leer, guardarContextoSalto: puerto.guardar, ); return handler; } Future premium() async { SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); } group('escritura — el contexto se fija en TODO camino que arranca una ' 'emisora, no solo en el del árbol del coche', () { setUp(premium); test('playFromMediaId (toque en el árbol del coche)', () async { registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playFromMediaId('emisora:rock1'); await pumpEventQueue(); expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock')); }); test('playFromSearch (voz)', () async { registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playFromSearch('jazz1'); await pumpEventQueue(); expect(puerto.guardados.last, const ContextoSalto.favoritos()); }); test('playMediaItem (el camino del móvil: ServicioAudio.reproducir)', () async { registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock2, l10n: textos())); await pumpEventQueue(); expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock')); }); test('una PISTA LOCAL no escribe contexto de emisora: su cola es otra ' 'cosa y pisarlo dejaría la radio recorriendo una lista ajena', () async { registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playMediaItem( const MediaItem( id: 'content://media/documents/pista-1', title: 'Pista local', ), ); await pumpEventQueue(); expect(puerto.guardados, isEmpty); }); }); group('lectura — el contexto recordado sobrevive al reinicio del proceso', () { setUp(premium); test('la fuente aún no está registrada cuando arranca la emisora (carrera ' 'real del bind en frío): el salto usa el contexto de la sesión ' 'anterior en vez de morir', () async { puerto.persistido = const ContextoSalto.grupo('g-rock'); // Bind en frío: `main.dart` todavía no ha llamado a // registrarFuenteNavegacion cuando el coche pide reproducir. registrarFuenteNavegacion(_FuenteFalsa()); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); // La fuente llega después, ya con las listas cargadas. registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), ); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, rock2.url); expect(puerto.lecturas, greaterThan(0)); }); test('el contexto recordado MANDA sobre la rederivación: el registro de ' 'favoritos perdió el grupo, y aun así se recorre el grupo', () async { // Escenario real: el snapshot que empuja el móvil llega antes de que // los grupos estén cargados, así que `favoritos()` reporta // «sin asignar» un rato. Sin memoria, el salto se ensancha a todos los // favoritos justo en mitad del trayecto. puerto.persistido = const ContextoSalto.grupo('g-rock'); registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); guion.urlsSolicitadas.clear(); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, rock2.url); }); }); group('degradación del contexto recordado', () { setUp(premium); test('el grupo recordado fue BORRADO -> se recorren todos los favoritos', () async { puerto.persistido = const ContextoSalto.grupo('g-rock'); registrarFuenteNavegacion( _FuenteFalsa( favoritos: [ favorita('rock1', GrupoFavoritos.sinAsignarId), jazz1, jazz2, ], grupos: const [ GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), ], ), ); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); guion.urlsSolicitadas.clear(); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, jazz1.url); }); test('el grupo SIGUE VIVO pero la emisora se salió de él -> se PERMANECE ' 'en el grupo y suena su PRIMERA emisora', () async { registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, rock2, jazz1], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock')); guion.urlsSolicitadas.clear(); // El móvil saca rock1 del grupo a media marcha: g-rock sigue existiendo // y sigue teniendo emisoras, solo que ya no la que suena. registrarFuenteNavegacion( _FuenteFalsa( // jazz1 va DELANTE de rock2 a propósito: si el contexto se // ensanchara a todos los favoritos, «siguiente» sonaría jazz1. favoritos: [ favorita('rock1', GrupoFavoritos.sinAsignarId), jazz1, rock2, rock3, ], grupos: gruposVivos, ), ); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, rock2.url); expect( puerto.persistido, const ContextoSalto.grupo('g-rock'), reason: 'el grupo sobrevive, así que el contexto no se rederiva', ); }); test('el grupo sigue vivo con UNA sola emisora -> el salto NO se ensancha ' 'a todos los favoritos: se queda donde está', () async { registrarFuenteNavegacion( _FuenteFalsa( favoritos: [rock1, rock2, jazz1, jazz2], grupos: gruposVivos, ), ); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); guion.urlsSolicitadas.clear(); // rock2 sale del grupo: g-rock se queda solo con la emisora que suena. registrarFuenteNavegacion( _FuenteFalsa( favoritos: [ rock1, favorita('rock2', GrupoFavoritos.sinAsignarId), jazz1, jazz2, ], grupos: gruposVivos, ), ); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas, isEmpty); expect(puerto.persistido, const ContextoSalto.grupo('g-rock')); }); test('el grupo fue BORRADO y la emisora ya NO es favorita -> suena la ' 'PRIMERA de los favoritos que queden', () async { registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); guion.urlsSolicitadas.clear(); registrarFuenteNavegacion( _FuenteFalsa( favoritos: [jazz1, jazz2], grupos: const [ GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), ], ), ); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, jazz1.url); }); test('no queda ningún favorito y la emisora no está en ninguna lista -> ' 'no-op silencioso, nunca un salto arbitrario', () async { puerto.persistido = const ContextoSalto.grupo('g-rock'); registrarFuenteNavegacion(_FuenteFalsa(grupos: gruposVivos)); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); guion.urlsSolicitadas.clear(); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas, isEmpty); }); }); group('tier gratuito', () { test('la lista de salto queda CONGELADA en el orden curado: que cambie ' '`ultima_emisora_v1` a media marcha no reordena el recorrido, así ' 'que «anterior» vuelve a deshacer «siguiente»', () async { // `resolverEmisorasDestacadas` se recompone como // `[última reproducida, ...curadas]`, así que la lista SE REORDENA SOLA // según suena cada emisora. Recorrerla en vivo hacía que «anterior» // dejara de ser el inverso de «siguiente» a mitad de trayecto. final kexp = emisorasDestacadas[3]; final siguienteCurada = emisorasDestacadas[4]; SharedPreferences.setMockInitialValues({ claveUltimaEmisora: '{"uuid":"${kexp.uuid}","nombre":"${kexp.nombre}",' '"url":"${kexp.url}"}', }); final handler = handlerConPuerto(); // Fija la divergencia: la lista VIVA con `ultima` = KEXP empieza por // KEXP, así que recorrerla daría la primera curada. Sin esta línea el // test de abajo pasaría también con el comportamiento antiguo. final enVivo = await resolverEmisorasDestacadas(); expect( emisoraVecina(kexp, enVivo, haciaAtras: false)?.url, emisorasDestacadas.first.url, ); await handler.playFromMediaId('emisora:${kexp.uuid}'); await pumpEventQueue(); await handler.skipToNext(); await pumpEventQueue(); expect( guion.urlsSolicitadas.last, siguienteCurada.url, reason: 'en vivo la lista sería [KEXP, ...resto], y «siguiente» se iría a ' 'la primera curada en vez de a la que va detrás de KEXP', ); // El móvil (vivo) persiste la emisora que acaba de sonar. final prefs = await SharedPreferences.getInstance(); await prefs.setString( claveUltimaEmisora, '{"uuid":"${siguienteCurada.uuid}",' '"nombre":"${siguienteCurada.nombre}",' '"url":"${siguienteCurada.url}"}', ); await handler.skipToPrevious(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, kexp.url); }); test('una emisora ajena al set curado se ANTEPONE al recorrido congelado, ' 'para que ningún botón quede muerto', () async { SharedPreferences.setMockInitialValues({ claveUltimaEmisora: '{"uuid":"ajena","nombre":"Ajena",' '"url":"https://example.com/ajena"}', }); final handler = handlerConPuerto(); await handler.playFromMediaId('emisora:ajena'); await pumpEventQueue(); expect( puerto.guardados.last, ContextoSalto.destacadas([ 'ajena', ...emisorasDestacadas.map((e) => e.uuid), ]), ); await handler.skipToPrevious(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url); }); test('un contexto PREMIUM recordado NO se camina en tier gratuito: una ' 'cuenta degradada no sigue recorriendo el catálogo', () async { puerto.persistido = const ContextoSalto.grupo('g-rock'); registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); await pumpEventQueue(); guion.urlsSolicitadas.clear(); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url); expect( puerto.guardados.last.tipo, TipoContextoSalto.destacadas, reason: 'el contexto de un usuario free es SIEMPRE el set gratuito', ); }); test('free: aunque el grupo de favoritos siga VIVO, el recorrido no entra ' 'en él tampoco en el segundo salto', () async { // La nueva caída «grupo vivo -> su primera emisora» no puede convertirse // en una puerta trasera al contenido de pago. puerto.persistido = const ContextoSalto.grupo('g-rock'); registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, rock2, rock3], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); await pumpEventQueue(); guion.urlsSolicitadas.clear(); await handler.skipToNext(); await pumpEventQueue(); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas, [ emisorasDestacadas[1].url, emisorasDestacadas[2].url, ]); }); }); group('robustez del puerto', () { setUp(premium); test('sin puerto registrado el salto sigue funcionando por rederivación ' '(un test de widgets, o el arranque antes de main.dart)', () async { registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), ); final handler = crearHandler(); registrarHandler(handler); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); guion.urlsSolicitadas.clear(); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, rock2.url); }); test('un puerto que revienta degrada a rederivación en vez de dejar al ' 'conductor sin botones', () async { puerto.explota = true; registrarFuenteNavegacion( _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), ); final handler = handlerConPuerto(); await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); await pumpEventQueue(); guion.urlsSolicitadas.clear(); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, rock2.url); }); }); } /// Doble del puerto de persistencia que `main.dart` ata a /// `contexto_reproduccion.dart`. class _PuertoContexto { ContextoSalto? persistido; final guardados = []; int lecturas = 0; bool explota = false; Future leer() async { lecturas++; if (explota) throw StateError('disco ilegible'); return persistido; } Future guardar(ContextoSalto contexto) async { if (explota) throw StateError('disco de solo lectura'); guardados.add(contexto); persistido = contexto; } } class _FuenteFalsa implements FuenteEmisorasAuto { _FuenteFalsa({ List? favoritos, List? misEmisoras, List? todas, List? grupos, }) : _favoritos = favoritos ?? const [], _misEmisoras = misEmisoras ?? const [], _todas = todas ?? const [], _grupos = grupos ?? const []; final List _favoritos; final List _misEmisoras; final List _todas; final List _grupos; @override Future> favoritos() async => _favoritos; @override Future> misEmisoras() async => _misEmisoras; @override Future> todas() async => _todas; @override Future> grupos() async => _grupos; @override Future porUuid(String uuid) async { for (final lista in [_favoritos, _misEmisoras, _todas]) { for (final e in lista) { if (e.uuid == uuid) return e; } } for (final e in await resolverEmisorasDestacadas()) { if (e.uuid == uuid) return e; } return null; } @override void actualizarSnapshot({ List? favoritos, List? misEmisoras, List? todas, List? grupos, }) {} } /// Misma forma que el doble de `servicio_audio_auto_free_test.dart`. class _GuionReproductor { final urlsSolicitadas = []; _ReproductorFalso? ultimoReproductor; } class _ReproductorFalso extends AudioPlayer { _ReproductorFalso( this._guion, AudioPipeline pipeline, AudioLoadConfiguration carga, ) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) { _guion.ultimoReproductor = this; } final _GuionReproductor _guion; final _estados = StreamController.broadcast(); @override Stream get playerStateStream => _estados.stream; @override Future setUrl( String url, { Map? headers, Duration? initialPosition, bool preload = true, dynamic tag, }) async { _guion.urlsSolicitadas.add(url); return null; } @override Future play() async {} @override Future pause() async {} @override Future stop() async {} @override Future setVolume(double volume) async {} @override Future dispose() async { await _estados.close(); } }