import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:just_audio/just_audio.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/servicios/emisoras_destacadas.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/handlers_audio.dart'; /// Resuming the last station in Android Auto — the three defects that made a /// car-only session unable to remember, restart or even NAME what it was /// playing. /// /// Every test here runs with NO widget tree and NO browse source registered: /// that is the engine Android Auto actually starts /// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with /// no Activity), so `EstadoRadio` — the only thing that used to write /// `ultima_emisora_v1` — is never constructed there. /// /// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a /// session that happened only in the car never updated the key and the /// head unit was offered the station from the last time the PHONE was /// used. The same key feeds `resolverEmisorasDestacadas`, so the free /// tier's featured folder was stale too. /// /// A2. `play()` with no source called `_player.play()`, and /// `just_audio.dart:937-967` publishes `_playingSubject.add(true)` /// BEFORE the `_audioSource != null` gate — so the platform was never /// touched, the returned Future never completed, and `playing: true` /// was forwarded over `processingState: idle`. /// `AudioService.java:559-560` then runs `enterPlayingState()` while /// `getPlaybackState()` is `STATE_NONE`: a notification with a pause /// button, no audio, no title and no artwork (or a /// `ForegroundServiceStartNotAllowedException` on API 31+). /// /// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites /// are the duration update, `_cambiarFuente` and `stop` — so /// `audio_service.dart:1029-1033` returned before `setMediaItem` and the /// native side got no metadata at all. void main() { TestWidgetsFlutterBinding.ensureInitialized(); final crearHandler = registrarHandlersLiberables(); late _GuionReproductor guion; /// The free set's first station: resolvable from the binary alone, so it /// works on a bind where no browse source was ever registered — which is /// the whole point of these tests. const emisoraFip = Emisora( uuid: 'pw-destacada-fip', nombre: 'FIP', url: 'https://icecast.radiofrance.fr/fip-midfi.mp3', pais: 'France', codigoPais: 'FR', idioma: 'french', ); setUp(() { guion = _GuionReproductor(); PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) => _ReproductorFalso(guion, pipeline, carga); // Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ?? // false`) and no `ultima_emisora_v1`. SharedPreferences.setMockInitialValues({}); }); tearDown(() { PluriWaveAudioHandler.fabricaReproductorPrueba = null; }); group('A1 — el coche escribe la ultima emisora', () { test( 'playFromMediaId desde el coche persiste ESA emisora por el puerto ' 'inyectado, sin arbol de widgets', () async { final prefs = await SharedPreferences.getInstance(); final handler = crearHandler(); final guardadas = []; registrarHandler( handler, guardarUltimaEmisora: (emisora) async { guardadas.add(emisora); await guardarUltimaEmisoraPersistida(emisora, prefs: prefs); }, ); await handler.playFromMediaId('emisora:${emisoraFip.uuid}'); await pumpEventQueue(); expect( guardadas.map((e) => e.uuid), [emisoraFip.uuid], reason: 'a car-only session must update `ultima_emisora_v1` itself — ' '`EstadoRadio` is never built on a headless engine', ); final persistida = await ultimaEmisoraPersistida(prefs: prefs); expect(persistida?.uuid, emisoraFip.uuid); expect( persistida?.url, emisoraFip.url, reason: 'the record has to be PLAYABLE: it is what the recent root and ' '`resolverEmisorasDestacadas` hand back to the head unit', ); }, ); test('playMediaItem directo (voz, telefono) persiste igual', () async { final handler = crearHandler(); final guardadas = []; registrarHandler( handler, guardarUltimaEmisora: (emisora) async => guardadas.add(emisora), ); await handler.playMediaItem( const MediaItem( id: 'https://ejemplo/stream', title: 'Ejemplo', extras: {'uuid': 'uuid-ejemplo'}, ), ); await pumpEventQueue(); expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']); expect(guardadas.single.url, 'https://ejemplo/stream'); }); test( 'una pista local NO se persiste como ultima emisora', () async { final handler = crearHandler(); final guardadas = []; registrarHandler( handler, guardarUltimaEmisora: (emisora) async => guardadas.add(emisora), ); await handler.playMediaItem( const MediaItem( id: 'content://media/audio/7', title: 'Pista local', extras: {'documentId': 'doc-7'}, ), ); await pumpEventQueue(); expect( guardadas, isEmpty, reason: '`ultima_emisora_v1` feeds the recent root and the featured ' 'folder as an `emisora:` row — a `content://` track ' 'there is a row that does nothing when tapped', ); }, ); test('un fallo del puerto se traza y NUNCA propaga', () async { final handler = crearHandler(); registrarHandler( handler, guardarUltimaEmisora: (_) async => throw StateError('sin disco'), ); await expectLater( handler.playMediaItem( const MediaItem( id: 'https://ejemplo/stream', title: 'Ejemplo', extras: {'uuid': 'uuid-ejemplo'}, ), ), completes, ); await pumpEventQueue(); }); test('sin puerto (tests de widget, fakes) no pasa nada', () async { final handler = crearHandler(); registrarHandler(handler); await expectLater( handler.playMediaItem( const MediaItem( id: 'https://ejemplo/stream', title: 'Ejemplo', extras: {'uuid': 'uuid-ejemplo'}, ), ), completes, ); }); }); group('A2 — play() sin fuente no publica una sesion fantasma', () { test( 'con una emisora persistida, play() resuelve y arranca ESA emisora: el ' 'reproductor recibe su url', () async { final handler = crearHandler(); registrarHandler( handler, leerUltimaEmisora: () async => emisoraFip, ); await pumpEventQueue(); unawaited(handler.play().catchError((_) {})); await pumpEventQueue(); expect( guion.urlsSolicitadas, contains(emisoraFip.url), reason: '`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY ' 'straight into play(); on a cold engine there is no source, so ' 'it has to resolve the persisted station instead', ); }, ); test( 'y NINGUN estado publicado lleva playing:true sobre processingState ' 'idle', () async { final handler = crearHandler(); registrarHandler( handler, leerUltimaEmisora: () async => emisoraFip, ); await pumpEventQueue(); final fantasmas = []; final sub = handler.playbackState.listen((estado) { if (estado.playing && estado.processingState == AudioProcessingState.idle) { fantasmas.add(estado); } }); unawaited(handler.play().catchError((_) {})); await pumpEventQueue(); await sub.cancel(); expect( fantasmas, isEmpty, reason: 'playing:true over idle is what makes `AudioService.java:559` ' 'call enterPlayingState() with STATE_NONE — a PluriWave ' 'notification with a pause button, no audio and no title', ); }, ); test( 'sin nada persistido: no se toca el reproductor, no hay estado ' 'fantasma y play() no se queda colgado', () async { final handler = crearHandler(); registrarHandler(handler, leerUltimaEmisora: () async => null); await pumpEventQueue(); final fantasmas = []; final sub = handler.playbackState.listen((estado) { if (estado.playing && estado.processingState == AudioProcessingState.idle) { fantasmas.add(estado); } }); await expectLater( handler.play().timeout(const Duration(seconds: 2)), completes, ); await pumpEventQueue(); await sub.cancel(); expect( guion.llamadasPlay, 0, reason: 'with nothing to restore the player must not be touched at ' 'all: `just_audio` publishes playing:true before its source ' 'gate and never completes the future it returns', ); expect(guion.llamadasSetUrl, 0); expect(fantasmas, isEmpty); }, ); test( 'con una fuente ya abierta, play() sigue siendo la reanudacion de ' 'siempre (pausa -> play no reabre nada)', () async { final handler = crearHandler(); registrarHandler( handler, leerUltimaEmisora: () async => emisoraFip, ); await handler.playMediaItem( const MediaItem( id: 'https://ejemplo/stream', title: 'Ejemplo', extras: {'uuid': 'uuid-ejemplo'}, ), ); await pumpEventQueue(); await handler.pause(); final urlsAntes = List.from(guion.urlsSolicitadas); await handler.play(); await pumpEventQueue(); expect( guion.urlsSolicitadas, urlsAntes, reason: 'a resume must NOT re-open the source, and must never replace ' 'the live station with the persisted one', ); expect(handler.intencionReproducir, isTrue); }, ); }); group('A3 — arranque en frio: el coche recibe metadatos', () { test( 'con una emisora persistida se publica su mediaItem SIN arrancar ' 'reproduccion', () async { final handler = crearHandler(); registrarHandler( handler, leerUltimaEmisora: () async => emisoraFip, ); await pumpEventQueue(); expect( handler.mediaItem.value, isNotNull, reason: '`audio_service.dart:1029-1033` returns before setMediaItem ' 'when mediaItem is null, so a cold engine sent the head unit ' 'no metadata whatsoever', ); expect(handler.mediaItem.value?.id, emisoraFip.url); expect(handler.playbackState.value.playing, isFalse); expect( guion.llamadasSetUrl, 0, reason: 'publishing metadata must not open a stream: a cold bind ' 'happens on every reconnect and must stay silent', ); }, ); test('sin nada persistido el mediaItem sigue vacio', () async { final handler = crearHandler(); registrarHandler(handler, leerUltimaEmisora: () async => null); await pumpEventQueue(); expect(handler.mediaItem.value, isNull); }); test( 'una emisora que ya empezo a sonar NO es pisada por la siembra', () async { final handler = crearHandler(); final lectura = Completer(); registrarHandler(handler, leerUltimaEmisora: () => lectura.future); await handler.playMediaItem( const MediaItem( id: 'https://enVivo/stream', title: 'En vivo', extras: {'uuid': 'uuid-en-vivo'}, ), ); lectura.complete(emisoraFip); await pumpEventQueue(); expect( handler.mediaItem.value?.id, 'https://enVivo/stream', reason: 'the seed exists to fill a VOID; clobbering the live station ' 'would rename what the driver is listening to', ); }, ); }); group('getMediaItem resuelve tambien el set destacado', () { test( 'sin fuente de navegacion registrada, una emisora destacada resuelve', () async { final handler = crearHandler(); registrarHandler(handler); final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}'); expect( item, isNotNull, reason: '`porUuid` already falls back to the featured set, so the car ' 'could BROWSE a featured station and not resolve its media ' 'item — the asymmetry is the bug', ); expect(item?.id, 'emisora:${emisoraFip.uuid}'); expect(item?.title, emisoraFip.nombre); }, ); test('un id que no es de emisora sigue devolviendo null', () async { final handler = crearHandler(); registrarHandler(handler); expect(await handler.getMediaItem('pista:doc-1'), isNull); expect(await handler.getMediaItem('emisora:'), isNull); }); }); } /// Shared script/observation record for every [_ReproductorFalso] the handler /// builds (it rebuilds its player on every source change, so counters cannot /// live on the instance). class _GuionReproductor { int llamadasPlay = 0; int llamadasSetUrl = 0; final urlsSolicitadas = []; } /// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour /// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes /// `playing: true` BEFORE the `_audioSource != null` gate, and with no source /// it never touches the platform and never completes the future it returned. class _ReproductorFalso extends AudioPlayer { _ReproductorFalso( this._guion, AudioPipeline pipeline, AudioLoadConfiguration carga, ) : super(audioPipeline: pipeline, audioLoadConfiguration: carga); final _GuionReproductor _guion; final _estados = StreamController.broadcast(); /// A fresh player has no source, exactly like the real one. bool _fuenteCargada = false; @override Stream get playerStateStream => _estados.stream; @override Future setUrl( String url, { Map? headers, Duration? initialPosition, bool preload = true, dynamic tag, }) async { _guion.llamadasSetUrl++; _guion.urlsSolicitadas.add(url); _fuenteCargada = true; return null; } @override Future play() { _guion.llamadasPlay++; if (!_estados.isClosed) { _estados.add( PlayerState( true, _fuenteCargada ? ProcessingState.ready : ProcessingState.idle, ), ); } // The dangling future: with no source, upstream `play()` awaits a // `_playingSubject` transition the platform will never produce. if (!_fuenteCargada) return Completer().future; return Future.value(); } @override Future pause() async {} @override Future stop() async {} @override Future setVolume(double volume) async {} @override Future dispose() async { await _estados.close(); } }