import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/main.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'helpers/handlers_audio.dart'; /// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador. /// /// El disparador anterior era `View.maybeOf(context) != null` dentro de /// `didChangeDependencies`, con un latch de un solo uso y este comentario: /// «Que exista una View significa que hay Activity». La premisa es FALSA. /// /// `runApp` envuelve SIEMPRE el árbol en una `View` construida a partir de /// `platformDispatcher.implicitView`, y lanza `StateError` si no la hay /// (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). Así que /// en el motor headless que `audio_service` levanta sin Activity —el mismo /// que demostrablemente llega a `runApp`, ver la doc de /// `aplicarPoliticaOrientacion`— `View.maybeOf(context)` ya es no-nulo en el /// PRIMER `didChangeDependencies`. /// /// Consecuencia: el latch se gastaba durante el arranque headless, justo en /// el instante en que no podía conseguir nada (`_childrenSubjects` sigue /// vacío, y `notificarHijosCambiaron` es `_childrenSubjects[id]?.add(...)`, /// un no-op silencioso). Y no podía volver a dispararse nunca, porque /// `didChangeDependencies` no se re-ejecuta cuando más tarde se adjunta una /// Activity al MISMO motor cacheado. La vía de recuperación estaba muerta en /// los dos motores. void main() { TestWidgetsFlutterBinding.ensureInitialized(); final crearHandler = registrarHandlersLiberables(); group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () { test('resumed + coche ya suscrito + latch libre invalida', () { expect( debeInvalidarArbolAutoAlReanudar( estado: AppLifecycleState.resumed, hayCocheSuscrito: true, yaInvalidado: false, ), isTrue, ); }); test('sin suscripción del coche NO invalida — y por tanto no gasta el ' 'latch en el arranque headless', () { expect( debeInvalidarArbolAutoAlReanudar( estado: AppLifecycleState.resumed, hayCocheSuscrito: false, yaInvalidado: false, ), isFalse, reason: 'notificarHijosCambiaron solo empuja a un sujeto que ya existe, ' 'así que invalidar antes de que el coche se suscriba a NADA es ' 'demostrablemente un no-op', ); }); test('ningún estado del ciclo de vida distinto de resumed invalida', () { for (final estado in [ AppLifecycleState.detached, AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused, ]) { expect( debeInvalidarArbolAutoAlReanudar( estado: estado, hayCocheSuscrito: true, yaInvalidado: false, ), isFalse, reason: '$estado no significa «hay una Activity adjunta en primer ' 'plano»; solo resumed lo significa', ); } }); test('con el latch ya gastado no vuelve a invalidar (nada de tormenta ' 'de notificaciones)', () { expect( debeInvalidarArbolAutoAlReanudar( estado: AppLifecycleState.resumed, hayCocheSuscrito: true, yaInvalidado: true, ), isFalse, ); }); }); group('OrientacionResponsiveApp — cableado real del disparador', () { testWidgets('bajo pumpWidget/runApp SIEMPRE existe una View, que es ' 'exactamente por qué el disparador anterior no valía', (tester) async { await tester.pumpWidget( const OrientacionResponsiveApp(child: SizedBox.shrink()), ); expect( View.maybeOf(tester.element(find.byType(SizedBox))), isNotNull, reason: 'wrapWithDefaultView envuelve el árbol en una View o lanza ' 'StateError: no hay ningún motor bajo runApp sin View', ); }); testWidgets('arranque headless: hay View desde el primer frame, pero sin ' 'Activity ni coche suscrito el latch NO se gasta y sigue disponible ' 'para cuando el coche por fin navegue', (tester) async { final handler = crearHandler(); registrarHandler(handler); var invalidaciones = 0; registrarInvalidacionArbolAuto(() => invalidaciones++); await tester.pumpWidget( const OrientacionResponsiveApp(child: SizedBox.shrink()), ); await tester.pump(); expect( invalidaciones, 0, reason: 'el primer frame no prueba que haya Activity', ); // Incluso si un evento de ciclo de vida llegara en frío: el coche no // ha navegado nada todavía, así que no hay ningún sujeto al que // empujar y el latch debe sobrevivir. tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pump(); expect(invalidaciones, 0); // Ahora el coche navega la raíz (esto es lo que crea el sujeto), y la // siguiente vuelta a primer plano sí encuentra algo que invalidar. handler.subscribeToChildren(AudioService.browsableRootId); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pump(); expect(invalidaciones, 1); }); testWidgets('con el coche YA suscrito, adjuntar una Activity (resumed) ' 'empuja de verdad por el stream de hijos de la raíz', (tester) async { final handler = crearHandler(); registrarHandler(handler); // El coche navegó la raíz durante el arranque headless: el sujeto // existe y el head unit tiene el listado cacheado. final eventos = >[]; final sub = handler .subscribeToChildren(AudioService.browsableRootId) .listen(eventos.add); addTearDown(sub.cancel); await tester.pumpWidget( const OrientacionResponsiveApp(child: SizedBox.shrink()), ); await tester.pump(); expect( eventos, isEmpty, reason: 'todavía no hay Activity, solo una View', ); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pump(); expect( eventos, hasLength(1), reason: 'esta es la ÚNICA vía de recuperación cuando el registro del ' 'canal pluriwave/file_actions falló en el motor headless', ); // Y no una por cada rebote de ciclo de vida. tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pump(); expect(eventos, hasLength(1)); }); }); group('hayCocheSuscritoAlArbol', () { test('es false sin handler suscrito y true en cuanto el coche navega un ' 'id', () async { final handler = crearHandler(); registrarHandler(handler); expect(hayCocheSuscritoAlArbol(), isFalse); handler.subscribeToChildren(AudioService.browsableRootId); expect(hayCocheSuscritoAlArbol(), isTrue); }); }); }