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.
204 lines
7.6 KiB
Dart
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 {};
|
|
}
|