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/pista_local.dart'; import 'package:pluriwave/servicios/emisoras_destacadas.dart'; import 'package:pluriwave/servicios/musica_local_auto.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'; /// Free-tier Android Auto surface, handler side (fix/auto-quality-guidelines, /// items 9, 11, 12, 13, 14). /// /// Every test here runs with `_fuenteNavegacionGlobal` NEVER registered — /// this file never calls `registrarFuenteNavegacion`. That is the exact bind /// a Play reviewer performs: Android Auto starts the headless engine, and /// until (and even after) `main.dart` wires its sources, the car's browse and /// play paths must produce real, playable content out of the binary alone. void main() { TestWidgetsFlutterBinding.ensureInitialized(); final crearHandler = registrarHandlersLiberables(); late _GuionReproductor guion; setUp(() { guion = _GuionReproductor(); PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) => _ReproductorFalso(guion, pipeline, carga); // Fresh install = free tier: `esPremiumPersistido` is // `getBool('compra_premium_v1') ?? false`, and there is no trial key. SharedPreferences.setMockInitialValues({}); }); tearDown(() { PluriWaveAudioHandler.fabricaReproductorPrueba = null; PluriWaveAudioHandler.lectorLocalePlataforma = PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto; }); AppLocalizations textos() => lookupAppLocalizations(const Locale('es')); group('debeBloquearCambioDeEmisora — item 11', () { test('free + emisora gratuita: NO bloquea', () { expect( debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true), isFalse, ); }); test('free + emisora premium: bloquea (propiedad de seguridad original — ' 'un emisora: rancio de antes de una bajada de tier no puede ' 'sonar)', () { expect( debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false), isTrue, ); }); test('premium: nunca bloquea', () { expect( debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false), isFalse, ); expect( debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: true), isFalse, ); }); }); group('getChildren — item 9', () { test('sin fuente de navegación registrada, la carpeta gratuita devuelve ' '>= 3 items REPRODUCIBLES', () async { final handler = crearHandler(); final items = await handler.getChildren( ConstructorArbolAuto.idDestacadas, ); expect(items.length, greaterThanOrEqualTo(3)); expect(items.every((m) => m.playable == true), isTrue); expect(items.every((m) => m.id.startsWith('emisora:')), isTrue); }); test('la raíz gratuita es UNA carpeta y no paga el round trip nativo de ' 'estadoCarpeta()', () async { final fuenteLocal = _FuenteMusicaLocalEspia(); registrarFuenteMusicaLocal(fuenteLocal); final handler = crearHandler(); final raiz = await handler.getChildren(AudioService.browsableRootId); expect(raiz.map((m) => m.id), [ConstructorArbolAuto.idDestacadas]); expect( fuenteLocal.llamadasEstadoCarpeta, 0, reason: 'estadoCarpeta() viaja por un MethodChannel que NO existe en el ' 'motor headless; el tier gratuito no puede ver Música Local, así ' 'que preguntarlo solo añade una vía de fallo en la raíz', ); }); test('una carpeta premium NO devuelve una fila no reproducible: devuelve ' 'el contenido gratuito', () async { final handler = crearHandler(); final items = await handler.getChildren(ConstructorArbolAuto.idTodas); expect(items, isNotEmpty); expect(items.every((m) => m.playable == true), isTrue); expect(items.every((m) => m.id != 'premium:info'), isTrue); }); }); group('playFromMediaId — item 12', () { test('free + uuid gratuito: SUENA', () async { final handler = crearHandler(); final destacada = emisorasDestacadas.first; await handler.playFromMediaId('emisora:${destacada.uuid}'); await pumpEventQueue(); expect(guion.urlsSolicitadas, contains(destacada.url)); }); test('free + uuid premium: publica error CON errorCode y errorMessage ' 'localizado, nunca vuelve en silencio', () async { PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en'); final handler = crearHandler(); await handler.playFromMediaId('emisora:uuid-del-catalogo'); await pumpEventQueue(); final estado = handler.playbackState.value; expect(estado.processingState, AudioProcessingState.error); expect(estado.errorCode, isNotNull); expect( estado.errorMessage, lookupAppLocalizations(const Locale('en')).autoErrorEmisoraPremium, ); expect(guion.urlsSolicitadas, isEmpty); }); test('free + pista local: bloqueada, y también publica el error', () async { final handler = crearHandler(); await handler.playFromMediaId('pista:doc-id'); await pumpEventQueue(); expect( handler.playbackState.value.processingState, AudioProcessingState.error, ); }); test('free + uuid inexistente en NINGÚN sitio: error, no silencio', () async { final handler = crearHandler(); await handler.playFromMediaId('emisora:'); await pumpEventQueue(); expect( handler.playbackState.value.processingState, AudioProcessingState.error, ); }); }); group('playFromSearch — items 12 y 13', () { test('free: una consulta que casa con el set gratuito SUENA', () async { final handler = crearHandler(); final destacada = emisorasDestacadas.first; await handler.playFromSearch(destacada.nombre); await pumpEventQueue(); expect(guion.urlsSolicitadas, contains(destacada.url)); }); test('free: una consulta sin resultados publica error localizado, nunca ' 'vuelve en silencio', () async { PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es'); final handler = crearHandler(); await handler.playFromSearch('emisora que no existe en ningun sitio'); await pumpEventQueue(); final estado = handler.playbackState.value; expect(estado.processingState, AudioProcessingState.error); expect(estado.errorCode, isNotNull); expect(estado.errorMessage, textos().autoErrorBusquedaSinResultados); }); test('consulta VACÍA ("Reproduce PluriWave") arranca algo — free', () async { final handler = crearHandler(); await handler.playFromSearch(''); await pumpEventQueue(); expect(guion.urlsSolicitadas, isNotEmpty); }); test('consulta VACÍA arranca algo — PREMIUM (antes fallaba incluso para ' 'un cliente que había pagado)', () async { SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); final handler = crearHandler(); await handler.playFromSearch(' '); await pumpEventQueue(); expect(guion.urlsSolicitadas, isNotEmpty); }); test('consulta vacía prefiere la ÚLTIMA escuchada', () async { SharedPreferences.setMockInitialValues({ claveUltimaEmisora: '{"uuid":"uuid-ultima","nombre":"Ultima",' '"url":"https://ultima.example/stream"}', }); final handler = crearHandler(); await handler.playFromSearch(''); await pumpEventQueue(); expect(guion.urlsSolicitadas, ['https://ultima.example/stream']); }); }); group('un rechazo NO destruye una sesion que esta sonando — hallazgo 1', () { /// Puts the handler in the exact state the reviewer reproduces: a free /// station tapped from the browse tree and audibly playing. Future sonando() async { final handler = crearHandler(); await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); await pumpEventQueue(); guion.ultimoReproductor!.emitir( PlayerState(true, ProcessingState.ready), ); await pumpEventQueue(); expect( handler.playbackState.value.playing, isTrue, reason: 'precondicion: la emisora esta sonando', ); return handler; } test( 'una busqueda por voz fallida deja el estado publicado FUERA de error y ' 'sigue ofreciendo play/pause/stop', () async { final handler = await sonando(); // The free candidate set is only the six compiled-in stations, so // almost any spoken station name misses. That must not cost the // driver the whole now-playing screen. await handler.playFromSearch('BBC'); await pumpEventQueue(); final estado = handler.playbackState.value; expect( estado.processingState, isNot(AudioProcessingState.error), reason: 'AudioService.java:601-611 maps `error` to STATE_ERROR with the ' '`playing` flag IGNORED, so publishing it over live audio ' 'replaces the transport row with an error the session can never ' 'clear: playerStateStream is .distinct() (nothing more comes ' 'from a steadily playing ExoPlayer) and _bufferedSub re-asserts ' 'it ~2x/second through copyWith', ); expect( estado.playing, isTrue, reason: 'el audio sigue sonando; el estado tiene que decirlo', ); expect( estado.systemActions, containsAll([ MediaAction.play, MediaAction.pause, MediaAction.stop, ]), ); expect( estado.controls.map((c) => c.action), containsAll([MediaAction.pause, MediaAction.stop]), ); }, ); test('y el motivo del fallo SIGUE llegando al head unit', () async { PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es'); final handler = await sonando(); await handler.playFromSearch('BBC'); await pumpEventQueue(); // AudioService.java:541-544 calls `setErrorMessage` from `setState` // regardless of processingState, so the text still reaches // PlaybackStateCompat without STATE_ERROR. expect( handler.playbackState.value.errorMessage, textos().autoErrorBusquedaSinResultados, ); expect(handler.playbackState.value.errorCode, isNotNull); }); test( 'sin sesion viva el rechazo SI es terminal: error explicado (el caso en ' 'que el coche no tiene nada que perder)', () async { final handler = crearHandler(); await handler.playFromSearch('BBC'); await pumpEventQueue(); expect( handler.playbackState.value.processingState, AudioProcessingState.error, ); expect(handler.playbackState.value.errorMessage, isNotNull); }, ); /// CONTRACT for an action refusal published over a LIVE session /// (`_publicarErrorAuto`'s non-terminal branch): /// /// 1. the code and the message are published immediately and stand for /// [PluriWaveAudioHandler.ventanaErrorAccionAuto] (the two tests /// above pin step 1); /// 2. when that window elapses they are BOTH cleared, and nothing else /// about the state moves; /// 3. any real player transition arriving first clears them early — a /// genuine state change supersedes a stale refusal; /// 4. only the fields this refusal published are ever cleared, so a /// reconnect status message that replaced them survives. /// /// It has to be bounded: `_bufferedSub` republishes /// `playbackState.value.copyWith(...)` ~2x/second and `copyWith` carries /// every omitted field forward (audio_service.dart:400-427), so /// `AudioService.java:541-544` re-calls `setErrorMessage(code, msg)` on /// every one of those pushes. Without a clear, one voice miss makes the /// session advertise an error for the rest of the station's playback, /// over audible healthy audio. group('y el rechazo es TRANSITORIO: nada lo arrastra para siempre', () { setUp(() { PluriWaveAudioHandler.ventanaErrorAccionAuto = const Duration( milliseconds: 80, ); }); tearDown(() { PluriWaveAudioHandler.ventanaErrorAccionAuto = PluriWaveAudioHandler.ventanaErrorAccionAutoPorDefecto; }); test('al pasar la ventana, codigo y mensaje desaparecen', () async { final handler = await sonando(); await handler.playFromSearch('BBC'); await pumpEventQueue(); expect( handler.playbackState.value.errorCode, isNotNull, reason: 'precondicion: el rechazo se publico', ); await Future.delayed(const Duration(milliseconds: 250)); final estado = handler.playbackState.value; expect( estado.errorCode, isNull, reason: 'every later push carries the code forward through copyWith, so ' 'the session would keep telling the head unit it is in error ' 'while the station plays perfectly', ); expect(estado.errorMessage, isNull); expect( estado.playing, isTrue, reason: 'clearing the refusal must not touch the session itself', ); expect( estado.processingState, isNot(AudioProcessingState.error), reason: 'nor its processing state', ); }); test( 'y un cambio de estado real del reproductor los limpia antes', () async { final handler = await sonando(); await handler.playFromSearch('BBC'); await pumpEventQueue(); expect(handler.playbackState.value.errorMessage, isNotNull); // The driver pauses: a genuine transition. `manejarEstadoPlayer` // omitted both fields, so `copyWith` carried the refusal into the // paused state and every state after it. handler.manejarEstadoPlayer( PlayerState(false, ProcessingState.ready), ); await pumpEventQueue(); final estado = handler.playbackState.value; expect(estado.errorCode, isNull); expect(estado.errorMessage, isNull); }, ); test('y stop() limpia el codigo, no solo el mensaje', () async { final handler = await sonando(); await handler.playFromSearch('BBC'); await pumpEventQueue(); expect(handler.playbackState.value.errorCode, isNotNull); await handler.stop(); await pumpEventQueue(); final estado = handler.playbackState.value; expect( estado.errorCode, isNull, reason: 'stop() cleared errorMessage but omitted errorCode, so the idle ' 'it publishes shipped a stale ERROR_CODE_PREMIUM_ACCOUNT_' 'REQUIRED (4) with no message to explain it', ); expect(estado.errorMessage, isNull); }); }); }); group('errorCode se limpia en un cambio de fuente — item 6', () { test('un rechazo premium seguido de una emisora gratuita NO arrastra el ' 'codigo de error', () async { final handler = crearHandler(); await handler.playFromMediaId('emisora:uuid-del-catalogo'); await pumpEventQueue(); expect( handler.playbackState.value.errorCode, isNotNull, reason: 'precondicion: el rechazo publico un codigo', ); await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); await pumpEventQueue(); expect( handler.playbackState.value.errorCode, isNull, reason: '`_cambiarFuente` cleared errorMessage but omitted errorCode, and ' 'copyWith carries an omitted field forward, so a stale code rode ' 'along indefinitely', ); expect(handler.playbackState.value.errorMessage, isNull); }); }); group('getChildren(recentRootId) — reanudacion del head unit', () { test('con una ultima emisora persistida devuelve EXACTAMENTE un item ' 'reproducible', () async { SharedPreferences.setMockInitialValues({ claveUltimaEmisora: '{"uuid":"uuid-ultima","nombre":"Ultima",' '"url":"https://ultima.example/stream"}', }); final handler = crearHandler(); final items = await handler.getChildren(AudioService.recentRootId); expect( items, hasLength(1), reason: 'onGetRoot (AudioService.java:817-821) answers "recent" whenever ' 'the head unit sends EXTRA_RECENT, which Android Auto does on ' 'reconnect, and the platform expects exactly one resume item — ' 'free tier used to fall through and return all six stations', ); expect(items.single.playable, isTrue); expect(items.single.id, 'emisora:uuid-ultima'); }); test('sin ultima emisora devuelve lista vacia y no lanza', () async { final handler = crearHandler(); expect(await handler.getChildren(AudioService.recentRootId), isEmpty); }); test('premium tambien obtiene su tile de reanudacion, no una lista ' 'vacia', () async { SharedPreferences.setMockInitialValues({ 'compra_premium_v1': true, claveUltimaEmisora: '{"uuid":"uuid-premium","nombre":"Premium",' '"url":"https://premium.example/stream"}', }); final handler = crearHandler(); final items = await handler.getChildren(AudioService.recentRootId); expect(items, hasLength(1)); expect(items.single.id, 'emisora:uuid-premium'); }); }); group('la raiz gratuita esta LOCALIZADA — hallazgo 4', () { test('en un motor headless en ingles la unica carpeta que un revisor de ' 'Play ve NO sale en castellano', () async { PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en'); final handler = crearHandler(); final raiz = await handler.getChildren(AudioService.browsableRootId); expect(raiz, hasLength(1)); expect( raiz.single.title, lookupAppLocalizations(const Locale('en')).autoCarpetaEscuchar, reason: 'raiz(premium: false) is 100% of the browse tree a Play reviewer ' 'ever sees, on a device in any of the 13 shipped locales — the ' 'hardcoded-Spanish car-label convention stops being defensible ' 'once one label IS the whole free root', ); expect(raiz.single.title, isNot('Escuchar')); }); test('en castellano sigue diciendo Escuchar', () async { PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es'); final handler = crearHandler(); final raiz = await handler.getChildren(AudioService.browsableRootId); expect(raiz.single.title, 'Escuchar'); }); }); group('botones de salto — item 14', () { test('free: el salto CICLA dentro del set gratuito en vez de no hacer ' 'nada', () async { final handler = crearHandler(); await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); await pumpEventQueue(); await handler.skipToNext(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url); }); test('free: el salto hacia atrás envuelve al final del set', () async { final handler = crearHandler(); await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); await pumpEventQueue(); await handler.skipToPrevious(); await pumpEventQueue(); expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url); }); test('free: los botones se SIGUEN anunciando (un boton que funciona es ' 'mejor UX que un hueco, y controls/systemActions se construyen en un ' 'listener sincrono que no puede await-ear prefs)', () async { final handler = crearHandler(); await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); await pumpEventQueue(); // `controls`/`systemActions` are only rebuilt from a real player event, // so the double has to emit one for this assertion to mean anything. guion.ultimoReproductor!.emitir( PlayerState(true, ProcessingState.ready), ); await pumpEventQueue(); final estado = handler.playbackState.value; expect(estado.systemActions, contains(MediaAction.skipToNext)); expect(estado.systemActions, contains(MediaAction.skipToPrevious)); }); }); } /// Cuenta los round trips nativos que la raíz del árbol dispara. class _FuenteMusicaLocalEspia implements FuenteMusicaLocalAuto { int llamadasEstadoCarpeta = 0; @override Future estadoCarpeta() async { llamadasEstadoCarpeta++; return EstadoCarpetaLocal.configurada; } @override Future> hijos(String documentId) async => const []; @override Future uriContenidoDePista(String documentId) async => null; @override Future> metadatosDe( List documentIds, ) async => const {}; } /// Misma forma que el doble de `servicio_audio_transporte_test.dart`. class _GuionReproductor { int llamadasPlay = 0; final urlsSolicitadas = []; /// The handler rebuilds its player on every source change, so a test that /// needs to drive player events has to reach the LATEST instance. _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(); void emitir(PlayerState estado) => _estados.add(estado); @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 { _guion.llamadasPlay++; } @override Future pause() async {} @override Future stop() async {} @override Future setVolume(double volume) async {} @override Future dispose() async { await _estados.close(); } }