Files
pluriwave/test/arranque_invalidacion_arbol_auto_test.dart
T
FreeTLab 3449e2cb79
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

1. Ecualizador: el estado no tenia dueño unico

El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.

Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.

Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.

El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.

2. Musica Local no aparecia en el arbol de Android Auto

hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.

La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.

EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.

3. El paywall bloqueaba las compras

restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-08-31 14:34:49 +02:00

204 lines
7.3 KiB
Dart

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';
/// 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();
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 = PluriWaveAudioHandler();
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 = PluriWaveAudioHandler();
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 = <Map<String, dynamic>>[];
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 = PluriWaveAudioHandler();
registrarHandler(handler);
expect(hayCocheSuscritoAlArbol(), isFalse);
handler.subscribeToChildren(AudioService.browsableRootId);
expect(hayCocheSuscritoAlArbol(), isTrue);
});
});
}