import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/pista_local.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'; /// Android Auto play-path backstop (design.md ADR-4, android-auto-media /// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station /// Playback Unaffected By Free Tier"): `playFromMediaId`, `playFromSearch`, /// `skipToNext`, `skipToPrevious` must ALL no-op for a free-tier user, /// regardless of the target id — gating `getChildren` alone is /// insufficient because a head unit caches browse trees, so a stale /// `emisora:` tap could otherwise bypass browsing entirely. Pure — /// `PluriWaveAudioHandler` cannot be instantiated in a unit test (needs a /// real platform `AudioPlayer`), so this is the extracted decision the /// handler's dispatch methods delegate to (mirrors `mapearEstadoProceso` /// and every other pure helper in this file). void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('free tier: bloquea cualquier cambio de emisora/salto', () { expect(debeBloquearCambioDeEmisora(premium: false), isTrue); }); test('premium: nunca bloquea', () { expect(debeBloquearCambioDeEmisora(premium: true), isFalse); }); /// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la /// transición free -> premium». Android Auto cachea la raíz, así que /// CUALQUIER momento en el que el árbol pasa a poder mostrar algo que /// antes no podía tiene que invalidarla — muy en particular, que aparezca /// por fin una Activity (y con ella el handler nativo del canal /// `pluriwave/file_actions`) o que el usuario acabe de elegir carpeta. /// De ahí el nombre neutro. group('invalidarArbolAuto / registrarInvalidacionArbolAuto', () { test('sin hook registrado, es un no-op seguro', () { expect(() => invalidarArbolAuto(), returnsNormally); }); test('invoca el hook registrado exactamente una vez por llamada', () { var llamadas = 0; registrarInvalidacionArbolAuto(() => llamadas++); invalidarArbolAuto(); expect(llamadas, 1); }); test('registrarHandler conecta la invalidación al handler: una llamada ' 'notifica la raíz Y Música Local', () async { final handler = PluriWaveAudioHandler(); registrarHandler(handler); final raiz = >[]; final local = >[]; final subRaiz = handler .subscribeToChildren(AudioService.browsableRootId) .listen(raiz.add); final subLocal = handler .subscribeToChildren(ConstructorArbolAuto.idMusicaLocal) .listen(local.add); invalidarArbolAuto(); await Future.delayed(Duration.zero); await subRaiz.cancel(); await subLocal.cancel(); expect(raiz, hasLength(1)); expect(local, hasLength(1)); }); }); /// fix/android-auto-musica-local, item 5. /// /// `subscribeToChildren` sembraba el `BehaviorSubject` con un mapa vacío. /// El listener interno de `audio_service` se suscribe en cuanto el head /// unit navega un id, recibe ESE valor semilla de inmediato y lo reenvía /// como `notifyChildrenChanged` — o sea, el primer browse de cada id /// provocaba un segundo `getChildren` espurio. En la raíz eso era un /// SEGUNDO round trip de permisos justo en la ruta que ya estaba /// fallando. Sin semilla no hay valor que reenviar, y la invalidación /// explícita (`notificarHijosCambiaron`) sigue funcionando igual. group('subscribeToChildren', () { test('el sujeto arranca SIN valor: nada que reenviar en la primera ' 'suscripción, así que no hay notifyChildrenChanged espurio', () { final handler = PluriWaveAudioHandler(); expect(handler.subscribeToChildren('musica_local').hasValue, isFalse); }); test('memoiza por id: dos llamadas devuelven el MISMO stream', () { final handler = PluriWaveAudioHandler(); expect( identical( handler.subscribeToChildren('musica_local'), handler.subscribeToChildren('musica_local'), ), isTrue, ); expect( identical( handler.subscribeToChildren('musica_local'), handler.subscribeToChildren('favoritos'), ), isFalse, ); }); test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito', () async { final handler = PluriWaveAudioHandler(); final stream = handler.subscribeToChildren('musica_local'); final recibidos = >[]; final sub = stream.listen(recibidos.add); handler.notificarHijosCambiaron('musica_local'); await Future.delayed(Duration.zero); await sub.cancel(); expect(recibidos, hasLength(1)); }); }); /// fix/android-auto-musica-local — «no aparece la opción de reproducir /// música local, no aparece ni el menú», con la compra PRO hecha. /// /// La raíz decidía la existencia del nodo con un round trip de permisos /// por `MethodChannel`. En el motor headless que Android Auto levanta sin /// Activity ese canal no tiene handler, la llamada lanzaba /// `MissingPluginException` y el nodo se omitía — y Android Auto CACHEA /// la raíz, así que se quedaba fuera toda la sesión. /// /// La pertenencia a la raíz ya no depende de poder contestar esa /// pregunta: basta con que el estado NO sea [EstadoCarpetaLocal.noConfigurada]. group('getChildren(root): pertenencia de Música Local', () { setUp(() { SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); }); Future> idsRaizCon(EstadoCarpetaLocal estado) async { registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado)); final handler = PluriWaveAudioHandler(); final items = await handler.getChildren(AudioService.browsableRootId); return items.map((i) => i.id).toList(); } test( 'canalNoDisponible (motor sin Activity, pero el usuario SÍ eligió ' 'carpeta): la raíz sigue ofreciendo Música Local', () async { expect( await idsRaizCon(EstadoCarpetaLocal.canalNoDisponible), contains(ConstructorArbolAuto.idMusicaLocal), ); }, ); test('configurada: la raíz ofrece Música Local', () async { expect( await idsRaizCon(EstadoCarpetaLocal.configurada), contains(ConstructorArbolAuto.idMusicaLocal), ); }); test( 'noConfigurada (nunca se eligió carpeta, o el permiso está revocado ' 'de verdad): la raíz sigue omitiendo Música Local', () async { expect( await idsRaizCon(EstadoCarpetaLocal.noConfigurada), isNot(contains(ConstructorArbolAuto.idMusicaLocal)), ); }, ); }); } /// Misma forma que `_FakeFuenteMusicaLocalAuto` en /// `navegacion_auto_test.dart`, reducida a lo que esta suite necesita: solo /// el estado de la carpeta decide la raíz. class _FakeFuenteMusicaLocalGating implements FuenteMusicaLocalAuto { _FakeFuenteMusicaLocalGating(this._estado); final EstadoCarpetaLocal _estado; @override Future estadoCarpeta() async => _estado; @override Future> hijos(String documentId) async => const []; @override Future uriContenidoDePista(String documentId) async => null; @override Future> metadatosDe( List documentIds, ) async => const {}; }