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.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||
|
||||
/// `FuenteMusicaLocalAuto.estadoCarpeta` promises, in its own interface doc,
|
||||
/// «Never throws: cualquier fallo degrada a un valor de
|
||||
/// [EstadoCarpetaLocal], nunca a una excepción». The refactor to the
|
||||
/// three-valued enum moved `await _uriPersistida()` OUTSIDE the try/catch,
|
||||
/// so a SharedPreferences failure escaped again — and the only caller,
|
||||
/// `PluriWaveAudioHandler.getChildren`'s root branch, awaits it inline, so
|
||||
/// the throw takes the whole browse root down and empties the car.
|
||||
///
|
||||
/// DELIBERATE FILE SEPARATION: nothing here may call
|
||||
/// `SharedPreferences.setMockInitialValues`. That call swaps
|
||||
/// `SharedPreferencesStorePlatform.instance` for an in-memory store for the
|
||||
/// rest of the isolate, and an in-memory store cannot fail. Left alone, the
|
||||
/// default store answers a real `getAll` platform call that nothing handles
|
||||
/// under `flutter test`, which is exactly the failure being exercised —
|
||||
/// hence its own file, not a group inside `musica_local_auto_test.dart`.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('FuenteMusicaLocalAutoImpl.estadoCarpeta — fallo de prefs', () {
|
||||
test('un fallo leyendo SharedPreferences degrada a noConfigurada en vez '
|
||||
'de propagar y vaciar la raíz del coche', () async {
|
||||
// Sin prefs inyectadas: `_resolverPrefs` cae en
|
||||
// `SharedPreferences.getInstance()`, que aquí lanza.
|
||||
final fuente = FuenteMusicaLocalAutoImpl();
|
||||
|
||||
await expectLater(
|
||||
fuente.estadoCarpeta(),
|
||||
completion(EstadoCarpetaLocal.noConfigurada),
|
||||
);
|
||||
});
|
||||
|
||||
test('ese fallo NO se reporta como canalNoDisponible, que es la única '
|
||||
'respuesta que significa «hay carpeta pero no puedo comprobar el '
|
||||
'permiso»', () async {
|
||||
final fuente = FuenteMusicaLocalAutoImpl();
|
||||
|
||||
final estado = await fuente.estadoCarpeta();
|
||||
|
||||
expect(
|
||||
estado,
|
||||
isNot(EstadoCarpetaLocal.canalNoDisponible),
|
||||
reason:
|
||||
'el fallo de prefs es una MissingPluginException igual que la '
|
||||
'del canal `pluriwave/file_actions`, así que un único try que '
|
||||
'las capturase juntas borraría la distinción de 3 valores: el '
|
||||
'árbol mostraría «Música Local» con un subárbol que explica un '
|
||||
'problema de canal inexistente',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -163,6 +163,132 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
/// fix/android-auto-musica-local — «Muchísimas veces (la mayoría) no
|
||||
/// aparece la opción de reproducir música local, no aparece ni el menú».
|
||||
///
|
||||
/// El usuario TIENE la compra PRO, así que no es un problema de
|
||||
/// entitlement. La causa real: `hasPersistedPermission` viaja por
|
||||
/// `MethodChannel('pluriwave/file_actions')`, cuyo ÚNICO registro de
|
||||
/// handler vive en `MainActivity.configureFlutterEngine`. Cuando Android
|
||||
/// Auto levanta el MediaBrowserService sin que la app se haya abierto,
|
||||
/// `audio_service` construye un FlutterEngine SIN Activity, ese método
|
||||
/// nunca corre, el canal se queda sin handler y `invokeMethod` lanza
|
||||
/// `MissingPluginException` — indistinguible hasta ahora de «permiso
|
||||
/// revocado».
|
||||
///
|
||||
/// Estos tests fijan la distinción: «el canal no está disponible» NO es
|
||||
/// «no hay carpeta».
|
||||
group('FuenteMusicaLocalAutoImpl.estadoCarpeta', () {
|
||||
const canal = MethodChannel('pluriwave/file_actions');
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, null);
|
||||
});
|
||||
|
||||
test(
|
||||
'con URI persistida y SIN handler nativo (motor headless de Android '
|
||||
'Auto) reporta canalNoDisponible, no noConfigurada',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri': 'content://tree/x',
|
||||
});
|
||||
// Sin handler: `invokeMethod` lanza MissingPluginException, que es
|
||||
// exactamente lo que pasa en el motor sin Activity.
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, null);
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(
|
||||
await fuente.estadoCarpeta(),
|
||||
EstadoCarpetaLocal.canalNoDisponible,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con URI persistida y handler nativo que responde false (permiso '
|
||||
'revocado de verdad) reporta noConfigurada',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri': 'content://tree/x',
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async {
|
||||
expect(call.method, 'hasPersistedPermission');
|
||||
return false;
|
||||
});
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con URI persistida y handler nativo que responde true reporta '
|
||||
'configurada',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri': 'content://tree/x',
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async => true);
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.configurada);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sin URI persistida reporta noConfigurada sin invocar el canal',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
var llamadas = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async {
|
||||
llamadas++;
|
||||
return true;
|
||||
});
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
|
||||
expect(llamadas, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'un PlatformException del canal (el handler SÍ existe, la llamada '
|
||||
'falla) reporta noConfigurada, no canalNoDisponible',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri': 'content://tree/x',
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async {
|
||||
throw PlatformException(code: 'ERROR');
|
||||
});
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('esArchivoAudio', () {
|
||||
test('acepta cualquier MIME audio/*, en cualquier capitalización', () {
|
||||
expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue);
|
||||
|
||||
@@ -2130,6 +2130,72 @@ void main() {
|
||||
expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull);
|
||||
});
|
||||
|
||||
/// fix/android-auto-musica-local: la raíz ya no oculta el nodo cuando
|
||||
/// el canal nativo no está disponible, así que el subárbol tiene que
|
||||
/// EXPLICAR el problema en vez de abrirse vacío (una carpeta vacía se
|
||||
/// lee como «no tengo música», que es justo la conclusión equivocada).
|
||||
///
|
||||
/// La etiqueta va en castellano hardcodeado, como TODAS las etiquetas
|
||||
/// del árbol del coche en `navegacion_auto.dart` (ver
|
||||
/// `itemPremiumBloqueado`): convención establecida, nunca `AppLocalizations`.
|
||||
test('canalNoDisponible y carpeta vacía: la raíz local devuelve un item '
|
||||
'explicativo NO reproducible, no una carpeta vacía', () async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
estado: EstadoCarpetaLocal.canalNoDisponible,
|
||||
);
|
||||
|
||||
final items = await hijosMusicaLocal(
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(items, isNotNull);
|
||||
expect(items!.map((i) => i.id), [ConstructorArbolAuto.idLocalNoLista]);
|
||||
expect(items.single.playable, isFalse);
|
||||
expect(items.single.title, isNotEmpty);
|
||||
});
|
||||
|
||||
test('canalNoDisponible pero CON pistas resueltas: no se entromete, se '
|
||||
'listan las pistas normalmente', () async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
estado: EstadoCarpetaLocal.canalNoDisponible,
|
||||
hijosPorDocId: {
|
||||
'': const [
|
||||
NodoLocal(
|
||||
documentId: 'd1',
|
||||
nombre: 'Cancion.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final items = await hijosMusicaLocal(
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(items, isNotNull);
|
||||
expect(
|
||||
items!.map((i) => i.id),
|
||||
isNot(contains(ConstructorArbolAuto.idLocalNoLista)),
|
||||
);
|
||||
expect(items.any((i) => i.id == 'pista:d1'), isTrue);
|
||||
});
|
||||
|
||||
test('carpeta genuinamente vacía con el canal SÍ disponible: sigue '
|
||||
'devolviendo lista vacía, sin item explicativo', () async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||
|
||||
final items = await hijosMusicaLocal(
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(items, isNotNull);
|
||||
expect(items, isEmpty);
|
||||
});
|
||||
|
||||
test('fuente null (cold-start, nunca registrada) devuelve lista vacía '
|
||||
'para un id de música local válido, no null y sin lanzar', () async {
|
||||
final resultado = await hijosMusicaLocal(
|
||||
@@ -3630,7 +3696,7 @@ class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
|
||||
|
||||
class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
_FakeFuenteMusicaLocalAuto({
|
||||
bool configurada = true,
|
||||
EstadoCarpetaLocal estado = EstadoCarpetaLocal.configurada,
|
||||
Map<String, List<NodoLocal>>? hijosPorDocId,
|
||||
Map<String, String?>? uriPorDocId,
|
||||
Map<String, MetadatosPista>? metadatosPorDocId,
|
||||
@@ -3638,7 +3704,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
Object? errorEnUriContenido,
|
||||
Object? errorEnMetadatosDe,
|
||||
Set<String>? idsConErrorEnHijos,
|
||||
}) : _configurada = configurada,
|
||||
}) : _estado = estado,
|
||||
_hijosPorDocId = hijosPorDocId ?? const {},
|
||||
_uriPorDocId = uriPorDocId ?? const {},
|
||||
_metadatosPorDocId = metadatosPorDocId ?? const {},
|
||||
@@ -3647,7 +3713,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
_errorEnMetadatosDe = errorEnMetadatosDe,
|
||||
_idsConErrorEnHijos = idsConErrorEnHijos ?? const {};
|
||||
|
||||
final bool _configurada;
|
||||
final EstadoCarpetaLocal _estado;
|
||||
final Map<String, List<NodoLocal>> _hijosPorDocId;
|
||||
final Map<String, String?> _uriPorDocId;
|
||||
final Map<String, MetadatosPista> _metadatosPorDocId;
|
||||
@@ -3666,7 +3732,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
final List<List<String>> llamadasMetadatosDe = [];
|
||||
|
||||
@override
|
||||
Future<bool> hayCarpetaConfigurada() async => _configurada;
|
||||
Future<EstadoCarpetaLocal> estadoCarpeta() async => _estado;
|
||||
|
||||
@override
|
||||
Future<List<NodoLocal>> hijos(String documentId) async {
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState;
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// eq-estado-unico — the equalizer's on/off flag gets a SINGLE owner.
|
||||
///
|
||||
/// Reported bug: «alguna emisora parece que esta con la ecualizacion activada
|
||||
/// (suena muy alto) pero con el boton desactivado», and «pulsando sobre el
|
||||
/// boton de ecualizar en Android Auto tampoco activaba ni desactivaba».
|
||||
///
|
||||
/// The flag used to live in three independent copies — the handler's
|
||||
/// hardcoded `_ecualizadorActivo = true`, `EstadoEcualizador._activo`, and
|
||||
/// SharedPreferences — and the persisted value only ever reached the handler
|
||||
/// through `EstadoEcualizador.cargarPersistido()`, which a headless Android
|
||||
/// Auto engine (no Activity, no Provider tree, no `EstadoRadio._init`) never
|
||||
/// runs. So in the car the handler played with the equalizer forced ON while
|
||||
/// disk and the phone UI both said OFF.
|
||||
///
|
||||
/// NOTE on testability: the long-standing comment in `servicio_audio.dart`
|
||||
/// claiming `PluriWaveAudioHandler` "cannot be instantiated in a unit test
|
||||
/// (a real just_audio.AudioPlayer needs platform MethodChannels)" is WRONG
|
||||
/// as of just_audio 0.9.46 — `AudioPlayer`'s constructor resolves its
|
||||
/// platform lazily and never becomes active without a `setUrl`, so the
|
||||
/// handler constructs fine here and every EQ path that does not touch the
|
||||
/// native effect is directly exercisable. That is what the dispatch tests
|
||||
/// below rely on; only the native `setEnabled`/`setGain` calls stay out of
|
||||
/// reach (they sit behind `_eqDisponible`, which is `false` off-device).
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('estadoEqInicial (A — seed the handler from disk on every engine)', () {
|
||||
test('adopts the persisted value when there is one', () {
|
||||
expect(estadoEqInicial(persistido: false), isFalse);
|
||||
expect(estadoEqInicial(persistido: true), isTrue);
|
||||
});
|
||||
|
||||
test('defaults to ON only when nothing was ever persisted', () {
|
||||
expect(
|
||||
estadoEqInicial(persistido: null),
|
||||
isTrue,
|
||||
reason: 'a first install keeps the historical default (EQ on)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('registrarHandler (A — seeding)', () {
|
||||
test('consults the injected read port exactly once and seeds the handler '
|
||||
'with the persisted value', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
var lecturas = 0;
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerEqActivoPersistido: () async {
|
||||
lecturas++;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
|
||||
expect(
|
||||
handler.ecualizadorActivo,
|
||||
isFalse,
|
||||
reason: 'the handler must adopt what the phone UI persisted',
|
||||
);
|
||||
});
|
||||
|
||||
test('a read failure leaves the handler on the safe default instead of '
|
||||
'propagating', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerEqActivoPersistido: () async => throw StateError('sin disco'),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(handler.ecualizadorActivo, isTrue);
|
||||
});
|
||||
|
||||
test('without a read port the handler is left untouched (widget tests, '
|
||||
'fakes)', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
await handler.setEcualizadorActivo(false);
|
||||
|
||||
registrarHandler(handler);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(handler.ecualizadorActivo, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
/// A, CONSTRUCTION-WINDOW half. `_eqActivoPersistido` (the module-level
|
||||
/// cache behind `_ecualizadorActivo = estadoEqInicial(persistido: ...)`)
|
||||
/// had zero coverage on BOTH sides: replacing that initialiser with the old
|
||||
/// hardcoded `= true` left the suite green, and so did deleting the
|
||||
/// `_eqActivoPersistido = activo` write in `_aplicarEcualizadorActivo`.
|
||||
///
|
||||
/// The window it closes is real: `AudioService.init` builds the handler
|
||||
/// through its `builder` callback and only AFTER that future resolves does
|
||||
/// `main.dart` reach `registrarHandler`. A car tap landing inside that
|
||||
/// window would otherwise hit a handler whose flag had never seen disk.
|
||||
group('A (construction window) — a handler built after a disk read', () {
|
||||
test('a handler constructed AFTER a read port has already answered '
|
||||
'starts from the persisted value, not from a hardcoded default',
|
||||
() async {
|
||||
// One engine does the read `registrarHandler` performs in main.dart.
|
||||
final primero = PluriWaveAudioHandler();
|
||||
|
||||
// Pin the module cache to the OPPOSITE value first. Without this the
|
||||
// test passes for the wrong reason: whatever ran before may already
|
||||
// have left the cache on `false`, so deleting the disk→cache write in
|
||||
// `_sembrarEcualizadorDesdeDisco` would still leave this green. Seeding
|
||||
// does NOT write the cache (`_aplicarEcualizadorActivo` returns before
|
||||
// it when `persistir: false`), so after this pin that write is the only
|
||||
// path that can bring the cache back down to `false`.
|
||||
await primero.setEcualizadorActivo(true);
|
||||
|
||||
registrarHandler(primero, leerEqActivoPersistido: () async => false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(primero.ecualizadorActivo, isFalse);
|
||||
|
||||
// Now the construction window: a handler built by `AudioService.init`'s
|
||||
// builder, with no port of its own yet.
|
||||
final segundo = PluriWaveAudioHandler();
|
||||
|
||||
expect(
|
||||
segundo.ecualizadorActivo,
|
||||
isFalse,
|
||||
reason:
|
||||
'a car tap landing before registrarHandler must not find the '
|
||||
'equalizer forced on while disk says off',
|
||||
);
|
||||
});
|
||||
|
||||
test('the cache follows what the handler itself writes, in both '
|
||||
'directions', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
await handler.setEcualizadorActivo(false);
|
||||
expect(
|
||||
PluriWaveAudioHandler().ecualizadorActivo,
|
||||
isFalse,
|
||||
reason:
|
||||
'the write side of the cache: a toggle must be visible to the '
|
||||
'next handler built on this engine',
|
||||
);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
expect(PluriWaveAudioHandler().ecualizadorActivo, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('B — the handler persists its OWN toggle', () {
|
||||
test('an eq toggle writes through the injected port even with no '
|
||||
'EstadoEcualizador in play', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final escrituras = <bool>[];
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
|
||||
);
|
||||
|
||||
await handler.setEcualizadorActivo(false);
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
escrituras,
|
||||
[false, true],
|
||||
reason:
|
||||
'a car/notification toggle must survive a process restart '
|
||||
'without any UI object existing',
|
||||
);
|
||||
});
|
||||
|
||||
test('seeding from disk does NOT write back to disk', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final escrituras = <bool>[];
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerEqActivoPersistido: () async => false,
|
||||
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(handler.ecualizadorActivo, isFalse);
|
||||
expect(escrituras, isEmpty);
|
||||
});
|
||||
|
||||
test('a failing write port never breaks the toggle', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarEqActivoPersistido: (_) async => throw StateError('disco lleno'),
|
||||
);
|
||||
|
||||
await handler.setEcualizadorActivo(false);
|
||||
|
||||
expect(handler.ecualizadorActivo, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('decidirToggleEq (C — the customAction decision)', () {
|
||||
test('flips the current value', () {
|
||||
expect(
|
||||
decidirToggleEq(activoActual: true, eqDisponible: true).nuevoActivo,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
decidirToggleEq(activoActual: false, eqDisponible: true).nuevoActivo,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a native call is required only when the effect is attached', () {
|
||||
expect(
|
||||
decidirToggleEq(
|
||||
activoActual: true,
|
||||
eqDisponible: true,
|
||||
).requiereLlamadaNativa,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
decidirToggleEq(
|
||||
activoActual: true,
|
||||
eqDisponible: false,
|
||||
).requiereLlamadaNativa,
|
||||
isFalse,
|
||||
reason:
|
||||
'with no native Equalizer effect the flag still flips, but '
|
||||
'nothing is pushed to the platform',
|
||||
);
|
||||
});
|
||||
|
||||
test('the flag still flips with no native effect — the car button must '
|
||||
'never look inert', () {
|
||||
expect(
|
||||
decidirToggleEq(activoActual: false, eqDisponible: false).nuevoActivo,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('customAction dispatch (C — zero coverage before this)', () {
|
||||
test('the accionEqToggle literal routes through decidirToggleEq', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
await handler.customAction(accionEqToggle);
|
||||
expect(handler.ecualizadorActivo, isFalse);
|
||||
|
||||
await handler.customAction(accionEqToggle);
|
||||
expect(handler.ecualizadorActivo, isTrue);
|
||||
});
|
||||
|
||||
test('a car toggle persists through the same write port as a phone '
|
||||
'toggle', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final escrituras = <bool>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
|
||||
);
|
||||
// Explicit starting state. A fresh handler seeds `_ecualizadorActivo`
|
||||
// from the module-level `_eqActivoPersistido` cache, which any earlier
|
||||
// test in this file leaves at whatever it last wrote. This assertion
|
||||
// is about what the TOGGLE does, not about what the previous test
|
||||
// happened to leave behind — without these two lines, simply
|
||||
// reordering the tests silently flips the expectation to `[true]`.
|
||||
await handler.setEcualizadorActivo(true);
|
||||
escrituras.clear();
|
||||
|
||||
await handler.customAction(accionEqToggle);
|
||||
|
||||
expect(escrituras, [false]);
|
||||
});
|
||||
|
||||
test('an unknown custom action is a silent no-op', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
final antes = handler.ecualizadorActivo;
|
||||
|
||||
await handler.customAction('accion.inexistente');
|
||||
|
||||
expect(handler.ecualizadorActivo, antes);
|
||||
});
|
||||
});
|
||||
|
||||
group('debeReasertarEcualizadorNativo (D — re-assert on activation)', () {
|
||||
test('an idle -> active transition with the effect attached re-asserts', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
|
||||
estado: ProcessingState.ready,
|
||||
reproductorActivoAntes: false,
|
||||
eqDisponible: true,
|
||||
),
|
||||
isTrue,
|
||||
reason:
|
||||
"just_audio's AudioEffect.setEnabled only reaches the platform "
|
||||
'while the player is active, so a toggle made while stopped '
|
||||
'never landed natively',
|
||||
);
|
||||
});
|
||||
|
||||
test('staying active does not re-assert on every event', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
|
||||
estado: ProcessingState.ready,
|
||||
reproductorActivoAntes: true,
|
||||
eqDisponible: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('going idle does not re-assert', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
|
||||
estado: ProcessingState.idle,
|
||||
reproductorActivoAntes: true,
|
||||
eqDisponible: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('no attached effect never re-asserts', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
|
||||
estado: ProcessingState.ready,
|
||||
reproductorActivoAntes: false,
|
||||
eqDisponible: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('buffering/loading/completed already count as active', () {
|
||||
for (final estado in [
|
||||
ProcessingState.loading,
|
||||
ProcessingState.buffering,
|
||||
ProcessingState.completed,
|
||||
]) {
|
||||
expect(
|
||||
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
|
||||
estado: estado,
|
||||
reproductorActivoAntes: false,
|
||||
eqDisponible: true,
|
||||
),
|
||||
isTrue,
|
||||
reason:
|
||||
'$estado is a non-idle state, i.e. the platform player is '
|
||||
'attached and accepts effect calls',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/// D, WIRING half. Everything above this group tests the pure
|
||||
/// [PluriWaveAudioHandler.debeReasertarEcualizadorNativo] predicate and
|
||||
/// nothing else: deleting the `playerStateStream` listener's whole re-assert
|
||||
/// block — the `if (debeReasertar...) unawaited(_reasertarEcualizadorNativo())`
|
||||
/// call, the `_reproductorActivo = proc != ProcessingState.idle` edge
|
||||
/// tracking — left the suite green. That is the SAME producer-only hole that
|
||||
/// let a dead Android Auto EQ button ship, so it gets closed here rather than
|
||||
/// re-tested at the predicate.
|
||||
///
|
||||
/// [PluriWaveAudioHandler.manejarEstadoPlayer] IS the listener body — the
|
||||
/// same method `playerStateStream.listen` is subscribed to — so these
|
||||
/// drive the real handler through real player-state transitions.
|
||||
group('D (wiring) — the playerState idle -> active edge', () {
|
||||
PlayerState estado(ProcessingState proc, {bool playing = false}) =>
|
||||
PlayerState(playing, proc);
|
||||
|
||||
test('the first non-idle event re-asserts the native effect exactly '
|
||||
'once, and staying active never re-asserts again', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
expect(handler.reasercionesEcualizador, 0);
|
||||
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.loading));
|
||||
expect(
|
||||
handler.reasercionesEcualizador,
|
||||
1,
|
||||
reason:
|
||||
"just_audio's AudioEffect.setEnabled is a no-op while the "
|
||||
'platform player is detached, so a toggle made while stopped '
|
||||
'only lands on this edge',
|
||||
);
|
||||
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.buffering));
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.completed));
|
||||
expect(
|
||||
handler.reasercionesEcualizador,
|
||||
1,
|
||||
reason:
|
||||
'the player emits many events while active; re-asserting on '
|
||||
'each one would be a native call storm',
|
||||
);
|
||||
});
|
||||
|
||||
test('going idle re-arms the edge, so stop + play re-asserts again — '
|
||||
'this is the `_reproductorActivo = proc != idle` line', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
|
||||
expect(handler.reasercionesEcualizador, 1);
|
||||
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.idle));
|
||||
expect(
|
||||
handler.reasercionesEcualizador,
|
||||
1,
|
||||
reason: 'going idle itself never re-asserts',
|
||||
);
|
||||
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
|
||||
expect(
|
||||
handler.reasercionesEcualizador,
|
||||
2,
|
||||
reason:
|
||||
'without the edge-tracking assignment the flag would stay true '
|
||||
'and the toggle made while stopped would never land natively',
|
||||
);
|
||||
});
|
||||
|
||||
test('with no native effect attached nothing is ever re-asserted', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
// `_eqDisponible` is false off-device, which is also the real
|
||||
// "device has no Equalizer effect" case.
|
||||
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.idle));
|
||||
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
|
||||
|
||||
expect(handler.reasercionesEcualizador, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('F — the EQ re-push must not rewind the car progress bar', () {
|
||||
test('the EQ controls re-push refreshes updatePosition from the '
|
||||
'player', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
handler.playbackState.add(
|
||||
handler.playbackState.value.copyWith(
|
||||
updatePosition: const Duration(minutes: 3),
|
||||
),
|
||||
);
|
||||
|
||||
await handler.setEcualizadorActivo(false);
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.updatePosition,
|
||||
handler.posicionActual,
|
||||
reason:
|
||||
'copyWith stamps a fresh updateTime but keeps the OLD '
|
||||
'updatePosition, so an EQ tap told the car "you are at 3:00, as '
|
||||
'of right now" and the bar snapped backwards',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the
|
||||
/// app's fixed ±12 dB slider scale to whatever range the device's native
|
||||
/// `Equalizer.getBandLevelRange()` reports.
|
||||
///
|
||||
/// This is the only source-plausible explanation for the reported «suena muy
|
||||
/// alto» half of the bug. The original implementation normalised the input
|
||||
/// across the WHOLE range and mapped it linearly:
|
||||
///
|
||||
/// normalizado = (db.clamp(-12, 12) + 12) / 24
|
||||
/// return minDecibels + normalizado * (maxDecibels - minDecibels)
|
||||
///
|
||||
/// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when
|
||||
/// the range happens to be symmetric. Android does not guarantee that: the
|
||||
/// AudioEffect Equalizer contract only requires a min/max pair, and real
|
||||
/// devices ship asymmetric ranges. On such a device a FLAT preset — every
|
||||
/// band 0 dB — was silently pushing a positive boost into every band, which
|
||||
/// is audibly louder while the on/off button still reads "off".
|
||||
///
|
||||
/// The contract asserted here: 0 dB always maps to exactly 0, and the two
|
||||
/// sides of the scale are stretched INDEPENDENTLY against their own end of
|
||||
/// the native range, so the sign of the user's intent is never inverted and
|
||||
/// the extremes still reach the device's real limits.
|
||||
void main() {
|
||||
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
|
||||
test('symmetric range (the common case) is unchanged', () {
|
||||
expect(
|
||||
mapearGananciaNativa(0, minDecibels: -15, maxDecibels: 15),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('asymmetric range no longer boosts a FLAT preset', () {
|
||||
// The reported symptom: on a device reporting [-12, +19] the old
|
||||
// midpoint mapping turned every 0 dB band into +3.5 dB of real boost.
|
||||
expect(
|
||||
mapearGananciaNativa(0, minDecibels: -12, maxDecibels: 19),
|
||||
0,
|
||||
reason: 'a FLAT preset must be inaudible, on every device',
|
||||
);
|
||||
});
|
||||
|
||||
test('a wholly positive range still cannot boost a FLAT preset', () {
|
||||
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
|
||||
});
|
||||
|
||||
test('a wholly negative range still cannot cut a FLAT preset', () {
|
||||
expect(mapearGananciaNativa(0, minDecibels: -19, maxDecibels: -3), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — the extremes reach the native limits', () {
|
||||
test('+12 dB maps to the native maximum', () {
|
||||
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19);
|
||||
});
|
||||
|
||||
test('-12 dB maps to the native minimum', () {
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
|
||||
});
|
||||
|
||||
test('values beyond the slider scale are clamped, not extrapolated', () {
|
||||
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15);
|
||||
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -15);
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — each side scales against its own end', () {
|
||||
test('half boost is half of the positive headroom', () {
|
||||
expect(
|
||||
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
|
||||
closeTo(10, 1e-9),
|
||||
);
|
||||
});
|
||||
|
||||
test('half cut is half of the negative headroom', () {
|
||||
expect(
|
||||
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
|
||||
closeTo(-6, 1e-9),
|
||||
);
|
||||
});
|
||||
|
||||
test('the sign of the user intent is never inverted', () {
|
||||
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
|
||||
final nativo = mapearGananciaNativa(
|
||||
db,
|
||||
minDecibels: -12,
|
||||
maxDecibels: 19,
|
||||
);
|
||||
expect(
|
||||
nativo.sign,
|
||||
db.sign,
|
||||
reason: 'a cut must never become a boost ($db dB -> $nativo)',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
|
||||
test('a range with no headroom on one side clamps that side to 0', () {
|
||||
// A device that reports max == 0 can only cut. Asking for a boost must
|
||||
// resolve to "no change", never to a negative value.
|
||||
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15);
|
||||
});
|
||||
|
||||
test('a zero-width range collapses everything to 0', () {
|
||||
expect(mapearGananciaNativa(12, minDecibels: 0, maxDecibels: 0), 0);
|
||||
expect(mapearGananciaNativa(-12, minDecibels: 0, maxDecibels: 0), 0);
|
||||
});
|
||||
|
||||
test('the result never escapes the native range', () {
|
||||
for (final db in [-12.0, -3.0, 0.0, 3.0, 12.0]) {
|
||||
final nativo = mapearGananciaNativa(
|
||||
db,
|
||||
minDecibels: -3,
|
||||
maxDecibels: 19,
|
||||
);
|
||||
expect(nativo, greaterThanOrEqualTo(-3));
|
||||
expect(nativo, lessThanOrEqualTo(19));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
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
|
||||
@@ -13,6 +18,8 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
/// 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);
|
||||
});
|
||||
@@ -21,18 +28,176 @@ void main() {
|
||||
expect(debeBloquearCambioDeEmisora(premium: true), isFalse);
|
||||
});
|
||||
|
||||
group('notificarDesbloqueoAuto / registrarNotificacionDesbloqueoAuto', () {
|
||||
/// 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(() => notificarDesbloqueoAuto(), returnsNormally);
|
||||
expect(() => invalidarArbolAuto(), returnsNormally);
|
||||
});
|
||||
|
||||
test('invoca el hook registrado exactamente una vez por llamada', () {
|
||||
var llamadas = 0;
|
||||
registrarNotificacionDesbloqueoAuto(() => llamadas++);
|
||||
registrarInvalidacionArbolAuto(() => llamadas++);
|
||||
|
||||
notificarDesbloqueoAuto();
|
||||
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 {};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,57 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:pluriwave/servicios/servicio_compras.dart';
|
||||
|
||||
/// Pure port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
|
||||
/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] is the ONLY
|
||||
/// piece of `ServicioComprasPlayBilling` that is unit-testable without a
|
||||
/// real `in_app_purchase` platform channel — [ServicioComprasPlayBilling]
|
||||
/// itself is the sole call site (Design ADR-2), exercised instead through
|
||||
/// `EstadoEntitlement` + a fake `PuertoCompras`
|
||||
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
|
||||
/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] es la parte
|
||||
/// pura, y [ServicioComprasPlayBilling] se ejercita inyectando
|
||||
/// [_InAppPurchaseFalso] — sin ningún platform channel real.
|
||||
/// `EstadoEntitlement` se prueba aparte con un `PuertoCompras` falso
|
||||
/// (`estado_entitlement_test.dart`).
|
||||
|
||||
/// Fake [InAppPurchase]: deja que cada test empuje lotes por
|
||||
/// [purchaseStream] a mano. `noSuchMethod` cubre el resto de la API del
|
||||
/// plugin, que estos tests no ejercitan.
|
||||
class _InAppPurchaseFalso implements InAppPurchase {
|
||||
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
|
||||
int restauracionesPedidas = 0;
|
||||
final completadas = <PurchaseDetails>[];
|
||||
|
||||
@override
|
||||
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
|
||||
|
||||
@override
|
||||
Future<void> restorePurchases({String? applicationUserName}) async {
|
||||
restauracionesPedidas++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> completePurchase(PurchaseDetails purchase) async {
|
||||
completadas.add(purchase);
|
||||
}
|
||||
|
||||
void emitir(List<PurchaseDetails> compras) => _compras.add(compras);
|
||||
|
||||
Future<void> dispose() => _compras.close();
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
|
||||
purchaseID: 'compra-1',
|
||||
productID: ServicioComprasPlayBilling.idProducto,
|
||||
verificationData: PurchaseVerificationData(
|
||||
localVerificationData: 'local',
|
||||
serverVerificationData: 'server',
|
||||
source: 'google_play',
|
||||
),
|
||||
transactionDate: null,
|
||||
status: status,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('eventoDesdeEstadoCompra', () {
|
||||
test('purchased -> comprada', () {
|
||||
@@ -50,6 +93,59 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('ServicioComprasPlayBilling.purchaseStream', () {
|
||||
test('un lote vacio emite noEncontrada (restaurar sin compras)', () async {
|
||||
final iap = _InAppPurchaseFalso();
|
||||
addTearDown(iap.dispose);
|
||||
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
||||
addTearDown(servicio.dispose);
|
||||
|
||||
final tipos = <TipoEventoCompra>[];
|
||||
final sub = servicio.eventos.listen((e) => tipos.add(e.tipo));
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await servicio.restaurar();
|
||||
iap.emitir(const <PurchaseDetails>[]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
// Sin este evento `EstadoEntitlement._compraEnCurso` se queda en `true`
|
||||
// para siempre y `hoja_premium.dart` deshabilita AMBOS botones
|
||||
// (comprar y restaurar): el usuario no puede pagar.
|
||||
expect(tipos, <TipoEventoCompra>[TipoEventoCompra.noEncontrada]);
|
||||
expect(iap.restauracionesPedidas, 1);
|
||||
});
|
||||
|
||||
test('un lote con compras NO emite noEncontrada', () async {
|
||||
final iap = _InAppPurchaseFalso();
|
||||
addTearDown(iap.dispose);
|
||||
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
||||
addTearDown(servicio.dispose);
|
||||
|
||||
final tipos = <TipoEventoCompra>[];
|
||||
final sub = servicio.eventos.listen((e) => tipos.add(e.tipo));
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
iap.emitir(<PurchaseDetails>[_compraFalsa(PurchaseStatus.restored)]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(tipos, <TipoEventoCompra>[TipoEventoCompra.restaurada]);
|
||||
});
|
||||
|
||||
test('completa las compras pendientes de confirmar', () async {
|
||||
final iap = _InAppPurchaseFalso();
|
||||
addTearDown(iap.dispose);
|
||||
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
||||
addTearDown(servicio.dispose);
|
||||
|
||||
final compra =
|
||||
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
|
||||
iap.emitir(<PurchaseDetails>[compra]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(iap.completadas, <PurchaseDetails>[compra]);
|
||||
});
|
||||
});
|
||||
|
||||
test('idProducto es el identificador unico no-consumible', () {
|
||||
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user