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/servicios/servicio_audio.dart'; import '../helpers/handlers_audio.dart'; /// Android for Cars App Quality Guidelines — transport state machine. /// /// Google Play returned "Approved with Issues" against version code 157: /// «clicking on stop button makes the entire app useless». These tests drive /// the REAL [PluriWaveAudioHandler] against a scripted [AudioPlayer] double /// (installed through [PluriWaveAudioHandler.fabricaReproductorPrueba]) so /// the published `playbackState` sequence — the only thing Android Auto ever /// sees — can be asserted end to end. void main() { TestWidgetsFlutterBinding.ensureInitialized(); final crearHandler = registrarHandlersLiberables(); late _GuionReproductor guion; setUp(() { guion = _GuionReproductor(); PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) => _ReproductorFalso(guion, pipeline, carga); }); tearDown(() { PluriWaveAudioHandler.fabricaReproductorPrueba = null; }); group('stop() durante cambios de fuente en vuelo (P0 — botón Stop)', () { test( 'dos cambios encolados y un stop: el ultimo estado publicado es idle, ' 'nunca vuelve a loading', () async { final handler = crearHandler(); final publicados = []; final sub = handler.playbackState.listen( (estado) => publicados.add(estado.processingState), ); unawaited( handler .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) .catchError((_) {}), ); unawaited( handler .playMediaItem(const MediaItem(id: 'https://b', title: 'B')) .catchError((_) {}), ); await handler.stop(); // Drain both queued source changes: they must discover the stale // revision WITHOUT ever publishing again. await pumpEventQueue(); await sub.cancel(); expect( publicados.last, AudioProcessingState.idle, reason: 'a stale queued source change must never rewrite `loading` over ' 'the `idle` that stop() published — that is what leaves Android ' 'Auto spinning forever on a dead session. Secuencia: $publicados', ); }, ); }); group('pause() durante un cambio de fuente en vuelo (P0 — botón Pausa)', () { test('la emisora NO arranca: _player.play() nunca se invoca', () async { guion.completerSetUrl = Completer(); final handler = crearHandler(); unawaited( handler .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) .catchError((_) {}), ); await pumpEventQueue(); expect( guion.llamadasSetUrl, 1, reason: 'precondicion: el cambio de fuente esta en vuelo', ); await handler.pause(); guion.completerSetUrl!.complete(null); await pumpEventQueue(); expect( guion.llamadasPlay, 0, reason: 'the user pressed Pause while the station was loading — the load ' 'finishing afterwards must never start playback behind their back', ); }); test( 'y el coche no se queda en el spinner: el estado publicado sale de ' 'loading', () async { guion.completerSetUrl = Completer(); final handler = crearHandler(); unawaited( handler .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) .catchError((_) {}), ); await pumpEventQueue(); await handler.pause(); guion.completerSetUrl!.complete(null); await pumpEventQueue(); expect( handler.playbackState.value.processingState, isNot(AudioProcessingState.loading), reason: 'withholding the play() must not leave the car showing the ' 'spinner the load started with — nothing else will publish, ' 'because the player never transitions', ); expect(handler.playbackState.value.playing, isFalse); }, ); }); group('Suelo de estado terminal (P0 — nunca un spinner eterno)', () { setUp(() { PluriWaveAudioHandler.vigilanciaTransitoria = const Duration( milliseconds: 60, ); }); tearDown(() { PluriWaveAudioHandler.vigilanciaTransitoria = PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto; }); test( 'un buffering publicado SIN carga viva cae a un estado terminal dentro ' 'de la ventana', () async { final handler = crearHandler(); handler.manejarEstadoPlayer( PlayerState(false, ProcessingState.buffering), ); expect( handler.playbackState.value.processingState, AudioProcessingState.buffering, reason: 'precondicion: el coche esta viendo el spinner', ); await Future.delayed(const Duration(milliseconds: 250)); expect( handler.playbackState.value.processingState, isIn(const [ AudioProcessingState.ready, AudioProcessingState.idle, AudioProcessingState.error, ]), reason: 'the only exits from loading/buffering are player events that ' '.distinct() can swallow — without a floor the car spins ' 'forever over a session nobody is driving', ); }, ); test('una carga LEGITIMA en vuelo no se interrumpe', () async { guion.completerSetUrl = Completer(); final handler = crearHandler(); unawaited( handler .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) .catchError((_) {}), ); await pumpEventQueue(); await Future.delayed(const Duration(milliseconds: 250)); expect( handler.playbackState.value.processingState, AudioProcessingState.loading, reason: 'the watchdog is a floor for a STALLED state machine, not a cap ' 'on how long a slow station may take to open', ); }); test( 'un mount estancado NO cae en un idle mudo: publica un motivo legible ' '(hallazgo 3)', () async { final handler = crearHandler(); // Exact shape of a stalled icecast mount: the socket opens, `setUrl` // returns inside the timeout (so no TimeoutException and no // PlayerException — `_esErrorDeRed` never fires and the reconnect // machine is never entered), and then no data ever arrives. State // sits at buffering with `_cambiosEnVuelo` already back to 0. handler.manejarEstadoPlayer( PlayerState(false, ProcessingState.buffering), ); await Future.delayed(const Duration(milliseconds: 250)); final estado = handler.playbackState.value; expect( estado.processingState, AudioProcessingState.error, reason: 'a bare `idle` routes straight into `AudioService._stop()` ' '(audio_service.dart:1131-1135), so the driver got silence, a ' 'dead session and no explanation. `error` keeps the session ' 'alive and carries a message', ); expect( estado.errorMessage, isNotNull, reason: 'the floor must say something the driver can read and act on', ); }, ); test( 'un rebuffer normal a mitad de emision NO se convierte en error ' '(regresion: el suelo miraba solo processingState)', () async { final handler = crearHandler(); // Established playback: the stream delivered audio and ExoPlayer // reached `ready` while playing. This is what separates a re-buffer // from a mount that never produced a byte. handler.manejarEstadoPlayer(PlayerState(true, ProcessingState.ready)); // Ordinary mid-stream re-buffer: `bufferForPlaybackAfterRebuffer` is // 5 s, so a tunnel or an LTE handover routinely holds this state for // longer than the floor's window. ExoPlayer raised no error, so // `_intentarReconexion` never ran and `reintentoPendiente` is false; // `_cambiosEnVuelo` is already 0 because the non-blocking // `_iniciarPlaySinBloquear` returned long ago. handler.manejarEstadoPlayer( PlayerState(true, ProcessingState.buffering), ); expect( handler.playbackState.value.processingState, AudioProcessingState.buffering, reason: 'precondicion: el reproductor esta rellenando el buffer', ); await Future.delayed(const Duration(milliseconds: 250)); final estado = handler.playbackState.value; expect( estado.processingState, isNot(AudioProcessingState.error), reason: 'a self-recovering re-buffer over live audio must never be ' 'converted into a hard STATE_ERROR: the driver is in a tunnel, ' 'not on a dead mount, and `_errorTerminal` latches so nothing ' 'the player emits afterwards could undo it', ); expect( estado.playing, isTrue, reason: 'the player still owns the timeline — publishing `playing: ' 'false` over it desynchronises the head unit transport row', ); expect( estado.errorMessage, isNull, reason: 'nothing failed, so there is nothing to tell the driver', ); }, ); test( 'un mount que NUNCA entrego audio sigue cayendo al suelo aunque el ' 'reproductor diga playing: true', () async { final handler = crearHandler(); // `just_audio`'s `playing` is the play-when-ready intent flag: it // flips to true the moment `play()` is called, whether or not a // single byte ever arrives. So the stalled icecast mount the floor // exists for reports `playing: true` too — `playing` alone can never // be the discriminator. handler.manejarEstadoPlayer( PlayerState(true, ProcessingState.buffering), ); await Future.delayed(const Duration(milliseconds: 250)); final estado = handler.playbackState.value; expect( estado.processingState, AudioProcessingState.error, reason: 'no `ready` was ever reached on this run, so nothing is ' 're-buffering: the driver is staring at a spinner and the floor ' 'is the only exit', ); expect(estado.errorMessage, isNotNull); }, ); test( 'la ventana del suelo respeta el presupuesto de diez segundos hasta el ' 'primer mensaje', () { expect( PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto, lessThanOrEqualTo(const Duration(seconds: 10)), reason: 'the floor is the ONLY exit for a stalled mount, so its window ' 'IS the time-to-first-message for that failure mode; twenty ' "seconds was double the code's own cited budget", ); }, ); }); group('Error terminal de reproduccion: la sesion sobrevive (hallazgo 2)', () { test( 'el ultimo estado publicado es error CON mensaje, y no lo sigue un idle', () async { // A non-network failure: `_esErrorDeRed` is false, so this goes // straight down the terminal path instead of the reconnect machine. guion.errorSetUrl = Exception('mount muerto'); final handler = crearHandler(); final publicados = []; final sub = handler.playbackState.listen( (estado) => publicados.add(estado.processingState), ); await handler .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) .catchError((_) {}); await pumpEventQueue(); // What `_player.stop()` really does: just_audio.dart:1016-1025 // switches to the idle dummy platform, so `playerStateStream` emits a // distinct (playing:false, idle). The double cannot do that on its // own, so the test drives the exact event the real player would. guion.ultimoReproductor!.emitir( PlayerState(false, ProcessingState.idle), ); await pumpEventQueue(); await sub.cancel(); final estado = handler.playbackState.value; expect( estado.processingState, AudioProcessingState.error, reason: 'forwarding that idle makes audio_service call ' 'AudioService._stop() -> deactivateMediaSession() + stopSelf(), ' 'so PluriWave dropped off the Android Auto playback surface a ' 'single event-loop turn after showing the error', ); expect(estado.errorMessage, isNotNull); expect( publicados.last, isNot(AudioProcessingState.idle), reason: 'secuencia publicada: $publicados', ); expect( handler.mediaItem.value, isNotNull, reason: 'Android Auto drops a session with no metadata to show, so ' 'nulling the media item on the error path makes the app vanish ' 'from the car pane even when the state itself survives — the ' 'station that failed has to keep its name on screen', ); }, ); test( 'un stop() del usuario DESPUES del error sigue produciendo un idle real ' '(la sesion tiene que poder morir cuando el conductor lo pide)', () async { guion.errorSetUrl = Exception('mount muerto'); final handler = crearHandler(); await handler .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) .catchError((_) {}); await pumpEventQueue(); expect( handler.playbackState.value.processingState, AudioProcessingState.error, reason: 'precondicion', ); await handler.stop(); await pumpEventQueue(); expect( handler.playbackState.value.processingState, AudioProcessingState.idle, reason: 'suppressing the error-driven idle must NEVER make the Stop ' 'button unkillable — that is the original citation', ); }, ); }); group('Presupuesto de tiempo hasta el primer mensaje (<= 10 s)', () { tearDown(() { PluriWaveAudioHandler.timeoutCambioFuente = PluriWaveAudioHandler.timeoutCambioFuentePorDefecto; }); test('el timeout por defecto deja el primer mensaje dentro de 10 s', () { expect( PluriWaveAudioHandler.timeoutCambioFuentePorDefecto, lessThanOrEqualTo(const Duration(seconds: 10)), reason: 'Android for Cars App Quality Guidelines allow ten seconds before ' 'the driver must be told something; the first attempt alone used ' 'to burn twelve', ); }); test( 'una fuente que nunca responde publica un mensaje visible al agotar el ' 'primer intento', () async { PluriWaveAudioHandler.timeoutCambioFuente = const Duration( milliseconds: 100, ); guion.setUrlCuelga = true; final handler = crearHandler(); unawaited( handler .playMediaItem(const MediaItem(id: 'https://muerta', title: 'X')) .catchError((_) {}), ); await Future.delayed(const Duration(milliseconds: 300)); expect( handler.playbackState.value.errorMessage, isNotNull, reason: 'the backoff used to publish `buffering` with errorMessage: ' 'null, so the car showed a silent spinner for the whole ~100 s ' 'reconnect window', ); expect( handler.playbackState.value.processingState, AudioProcessingState.buffering, reason: 'still retrying — the message rides ON TOP of the retry', ); }, ); test('los reintentos siguen DETRAS del mensaje', () async { PluriWaveAudioHandler.timeoutCambioFuente = const Duration( milliseconds: 100, ); guion.setUrlCuelga = true; final handler = crearHandler(); unawaited( handler .playMediaItem(const MediaItem(id: 'https://muerta', title: 'X')) .catchError((_) {}), ); // First backoff delay is 1 s (ControladorReconexion default). await Future.delayed(const Duration(milliseconds: 1300)); expect( guion.llamadasSetUrl, greaterThanOrEqualTo(2), reason: 'the reconnect machine keeps working after the first message', ); expect( handler.playbackState.value.errorMessage, isNotNull, reason: 'and the message survives the retry: re-entering `_cambiarFuente` ' 'must not blank the car screen back to a silent spinner', ); }); }); /// A handler nobody released goes on running: its terminal-state floor /// timer, its `ControladorReconexion` backoff (1/2/4/8/16 s, which easily /// outlives the test that armed it) and whatever is still queued on /// `_colaCambioFuente`. When one of those finally performs a source change /// it calls `_crearPlayer()`, which reads the CURRENT static /// `fabricaReproductorPrueba` — so it builds a double bound to a LATER /// test's script and increments that test's counters for work it never /// asked for. A suite that passes under those conditions passes by luck. group('Liberacion del handler: nada sobrevive al test que lo creo', () { tearDown(() { PluriWaveAudioHandler.timeoutCambioFuente = PluriWaveAudioHandler.timeoutCambioFuentePorDefecto; }); test( 'un handler liberado NO vuelve a construir un reproductor contra la ' 'fabrica del test siguiente', () async { PluriWaveAudioHandler.timeoutCambioFuente = const Duration( milliseconds: 60, ); guion.setUrlCuelga = true; final handler = crearHandler(); unawaited( handler .playMediaItem(const MediaItem(id: 'https://muerta', title: 'X')) .catchError((_) {}), ); // Long enough for the source-change timeout to fire and the reconnect // machine to arm its first backoff retry (1 s). await Future.delayed(const Duration(milliseconds: 200)); expect( guion.llamadasSetUrl, 1, reason: 'precondicion: hay un reintento armado detras', ); await handler.liberar(); // Exactly what the framework does between tests: a brand-new script // and a factory bound to it. Nothing from the previous test may // reach this. final guionSiguiente = _GuionReproductor(); PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) => _ReproductorFalso(guionSiguiente, pipeline, carga); await Future.delayed(const Duration(milliseconds: 1400)); expect( guionSiguiente.llamadasSetUrl, 0, reason: 'the leaked backoff retry re-enters `_cambiarFuente`, which ' 'calls `_crearPlayer()` and therefore reads whatever factory is ' 'installed NOW — attributing a dead handler s work to the test ' 'that happens to be running', ); expect( guionSiguiente.ultimoReproductor, isNull, reason: 'no player at all may be built against the new script', ); }, ); test('liberar() es idempotente', () async { final handler = crearHandler(); await handler.liberar(); await handler.liberar(); }); }); group('Idioma de la superficie del coche (motor sin Activity)', () { tearDown(() { PluriWaveAudioHandler.lectorLocalePlataforma = PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto; }); /// Drives a NON-network failure through the real source-change path so the /// terminal error message published to the car can be read back. Future mensajeDeError(PluriWaveAudioHandler handler) async { guion.errorSetUrl = Exception('boom'); await handler .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) .catchError((_) {}); await pumpEventQueue(); return handler.playbackState.value.errorMessage; } test( 'sin configurarLocalizaciones, los mensajes salen en el locale de la ' 'plataforma, no en es', () async { PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en'); final handler = crearHandler(); final mensaje = await mensajeDeError(handler); expect( mensaje, lookupAppLocalizations(const Locale('en')).audioErrorUnexpectedPlayback, reason: '`configurarLocalizaciones` only ever runs from ' '`mini_reproductor.dart` didChangeDependencies. The headless ' 'Android Auto engine has no Activity and no widget tree, so it ' 'never ran there and every car message came out in Spanish', ); expect( mensaje, isNot( lookupAppLocalizations( const Locale('es'), ).audioErrorUnexpectedPlayback, ), ); }, ); test('un locale de plataforma no soportado conserva el respaldo es', () async { PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('sw'); final handler = crearHandler(); expect( await mensajeDeError(handler), lookupAppLocalizations(const Locale('es')).audioErrorUnexpectedPlayback, reason: 'the existing fallback must survive an unresolvable locale', ); }); test('configurarLocalizaciones sigue teniendo prioridad', () async { PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en'); final handler = crearHandler(); handler.configurarLocalizaciones( lookupAppLocalizations(const Locale('fr')), ); expect( await mensajeDeError(handler), lookupAppLocalizations(const Locale('fr')).audioErrorUnexpectedPlayback, reason: 'the phone UI still owns the locale once a widget tree exists', ); }); }); } /// 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 = []; /// When set, `setUrl` completes with this error instead of succeeding. Object? errorSetUrl; /// When true, `setUrl` never completes (simulates a dead stream that only /// the source-change timeout can end). bool setUrlCuelga = false; /// When set, `setUrl` returns this completer's future, so a test can hold a /// source change mid-flight and release it after acting on the handler. Completer? completerSetUrl; /// The handler rebuilds its player on every source change, so a test that /// needs to drive a player event has to reach the LATEST instance. _ReproductorFalso? ultimoReproductor; } /// A [AudioPlayer] whose platform-touching methods are replaced by the script /// above. Everything else (the rx subjects the constructor wires up) is the /// real thing, so the handler's stream plumbing is exercised unchanged. 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(); /// Drives the exact `playerStateStream` event the real player would emit. void emitir(PlayerState estado) { if (!_estados.isClosed) _estados.add(estado); } @override Stream get playerStateStream => _estados.stream; @override Future setUrl( String url, { Map? headers, Duration? initialPosition, bool preload = true, dynamic tag, }) { _guion.llamadasSetUrl++; _guion.urlsSolicitadas.add(url); if (_guion.setUrlCuelga) return Completer().future; final pendiente = _guion.completerSetUrl; if (pendiente != null) return pendiente.future; final error = _guion.errorSetUrl; if (error != null) return Future.error(error); return Future.value(null); } @override Future play() async { _guion.llamadasPlay++; } @override Future pause() async {} @override Future stop() async {} @override Future setVolume(double volume) async {} @override Future dispose() async { await _estados.close(); } }