Files
pluriwave/test/servicios/servicio_audio_eq_estado_unico_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

475 lines
17 KiB
Dart

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',
);
});
});
}