Files
pluriwave/test/servicios/servicio_audio_gating_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.6 KiB
Dart

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:<uuid>` 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 = <Map<String, dynamic>>[];
final local = <Map<String, dynamic>>[];
final subRaiz = handler
.subscribeToChildren(AudioService.browsableRootId)
.listen(raiz.add);
final subLocal = handler
.subscribeToChildren(ConstructorArbolAuto.idMusicaLocal)
.listen(local.add);
invalidarArbolAuto();
await Future<void>.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 = <Map<String, dynamic>>[];
final sub = stream.listen(recibidos.add);
handler.notificarHijosCambiaron('musica_local');
await Future<void>.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<List<String>> 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<EstadoCarpetaLocal> estadoCarpeta() async => _estado;
@override
Future<List<NodoLocal>> hijos(String documentId) async => const [];
@override
Future<String?> uriContenidoDePista(String documentId) async => null;
@override
Future<Map<String, MetadatosPista>> metadatosDe(
List<String> documentIds,
) async => const {};
}