Files
pluriwave/test/servicios/servicio_audio_eq_auto_test.dart
T
FreeTLab 6b91ad88e8 fix: el ecualizador del coche aplica el preset real y en el orden correcto
Reportado desde el uso real: desde el movil el ecualizador va bien, pero el
boton de Android Auto a veces no hace nada y a veces suena como si se aplicara
una doble ecualizacion.

El preset del handler nunca se sembraba desde disco

`_presetActual` arrancaba en `flat` a fuego. registrarHandler sembraba el flag
de encendido pero no el preset, asi que en un motor donde la interfaz del
telefono nunca corrio -- el que arranca Android Auto -- el toggle del coche
aplicaba `flat`, o lo que hubiera quedado, en vez del preset del usuario. Es la
misma clase de fallo que ya se corrigio para el flag: aquel recibio un puerto
headless y el preset se quedo fuera. Ahora tiene el suyo, con la misma forma:
opcional, el fallo se traza y cae al valor por defecto, nunca propaga.

La siembra respeta un preset ya elegido por EstadoEcualizador, que es mas rico
que la clave principal, para que la lectura de disco en vuelo no lo pise.

El efecto se habilitaba antes de escribir las ganancias

La ruta era setEnabled -> setEnabled -> ganancias: `aplicarPreset` volvia a
llamar a setEnabled por su cuenta. Entre la habilitacion y la escritura sonaban
las ganancias anteriores, y ese hueco es lo que se percibia como doble
ecualizacion. Ahora una funcion pura devuelve los pasos en orden y ambas rutas
la recorren: ganancias primero, habilitacion despues.

Las ganancias NO se resetean al apagar, y es deliberado: setEnabled(false)
puentea el efecto sin liberarlo ni limpiar sus niveles, y la ruta de encendido
los reescribe enteros antes de habilitar, asi que no queda ninguna ventana de
ganancia rancia que un reset pudiera cerrar.

El boton desaparecia en cada cambio de emisora

`_recrearPlayer` bajaba `_eqDisponible` sin republicar controles, asi que cada
cambio de emisora emitia al menos un estado sin la accion de EQ. Peor: las
llamadas nativas estan detras de ese flag, de modo que un toggle en esa ventana
cambiaba el icono sin tocar el audio. Ahora el unico que lo escribe es
`_activarEcualizador`.

Mantenerlo optimista exigia quitar de la ruta del toggle el `await
_eq.parameters`, que es un Completer que solo se completa cuando el reproductor
se engancha: esperarlo dejaba el boton pendiente durante toda la carga, y para
siempre si la carga fallaba. Se cachean los parametros al activarse.

Los fallos nativos dejan de ser mudos

El `catch (_) {}` ocultaba que la llamada nativa habia fallado y dejaba el icono
afirmando un estado que el audio no tenia. Ahora se traza, y un fallo al
habilitar revierte el flag, republica los controles y no persiste.

EstadoEcualizador adopta lo que el motor acepto en vez de asumir que su peticion
prospero: sin eso, el telefono escribia en disco un valor que el handler acababa
de rechazar, reabriendo la divergencia que el dueño unico habia cerrado.

Suite completa: 1515 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00

375 lines
13 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// eq-coche — the equalizer toggle pressed FROM ANDROID AUTO.
///
/// Reported by the owner: the toggle behaves correctly from the phone screen
/// but from the car it «sometimes sounds like a doubled equalization and
/// sometimes does nothing».
///
/// Three independent causes, one per group below:
///
/// A. The handler's `_presetActual` was hardcoded to `PresetEcualizador.flat`
/// and had NO disk seam. The on/off flag got one (`leerEqActivoPersistido`,
/// `eq-estado-unico` item A); the preset never did. On a headless Android
/// Auto engine — no Activity, no Provider tree, so no `EstadoEcualizador`
/// to push the real preset — enabling the equalizer from the car applied
/// FLAT.
///
/// B. `_aplicarEcualizadorActivo` called `setEnabled(activo)` BEFORE pushing
/// the preset's gains, so the native effect was re-activated carrying
/// whatever band levels the previous preset had left in it and only
/// afterwards were the intended ones written, band by band. That audible
/// gap is the «doubled equalization».
///
/// C. `_recrearPlayer` dropped `_eqDisponible` to `false` on EVERY station
/// change and never restored it until the fresh player attached. Every
/// native EQ path is gated on that flag, so a car toggle landing inside
/// the window flipped the icon and the flag but never touched the audio —
/// the «does nothing» — and the EQ button itself vanished from the car's
/// now-playing screen (`controlesEcualizadorPersonalizados` returns
/// `const []` when unavailable) and came back seconds later.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductorEq guion;
setUp(() {
guion = _GuionReproductorEq();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalsoEq(guion, pipeline, carga);
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
PluriWaveAudioHandler.fabricaEcualizadorPrueba = null;
});
group('A — the preset is seeded from disk on a headless engine', () {
test('registrarHandler consults the injected preset port exactly once '
'and seeds the handler with it, with no widget tree', () async {
final handler = crearHandler();
var lecturas = 0;
registrarHandler(
handler,
leerPresetPersistido: () async {
lecturas++;
return PresetEcualizador.jazz;
},
);
await pumpEventQueue();
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
expect(
handler.presetActual,
PresetEcualizador.jazz,
reason:
'from the car the handler is the ONLY owner of the preset — '
'nothing else ever pushes one on a headless engine',
);
});
test('a read failure leaves the historical default instead of '
'propagating', () async {
final handler = crearHandler();
registrarHandler(
handler,
leerPresetPersistido: () async => throw StateError('sin disco'),
);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.flat);
});
test('without a preset port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = crearHandler();
await handler.aplicarPreset(PresetEcualizador.rock);
registrarHandler(handler);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.rock);
});
test('a preset already chosen while the disk read was in flight WINS — '
'seeding never clobbers a live choice', () async {
final handler = crearHandler();
final lectura = Completer<PresetEcualizador?>();
registrarHandler(handler, leerPresetPersistido: () => lectura.future);
// The phone UI (`EstadoEcualizador`) resolves a per-station preset and
// pushes it while the seed's disk read is still pending.
await handler.aplicarPreset(PresetEcualizador.pop);
lectura.complete(PresetEcualizador.jazz);
await pumpEventQueue();
expect(
handler.presetActual,
PresetEcualizador.pop,
reason:
'the seed exists to fill a VOID, not to overrule the richer '
'per-station/per-device preset the phone UI resolves',
);
});
});
group('B — the preset is pushed BEFORE the effect is enabled', () {
test('enabling applies the gains first and only then flips the native '
'effect on', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: true),
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'enabling first would re-activate the native Equalizer carrying '
'the PREVIOUS preset gains, which is the doubled equalization '
'the owner hears',
);
});
test('disabling only flips the effect off — the band gains are NOT '
'reset', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: false),
[PasoEcualizador.habilitacion],
reason:
'android.media.audiofx.AudioEffect.setEnabled(false) bypasses '
'the effect and RETAINS its band levels, and the enable path '
'rewrites them before re-enabling anyway — zeroing them would be '
'one native round trip per band for no audible difference',
);
});
test('the real toggle path executes those steps IN THAT ORDER', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.pasosEcualizadorEjecutados,
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'the ORDER is the fix; asserting only that both happened would '
'stay green against the exact bug being fixed',
);
});
test('the real disable path executes only the habilitacion step', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(false);
expect(handler.pasosEcualizadorEjecutados, [
PasoEcualizador.habilitacion,
]);
});
});
group('C — a station change no longer drops the equalizer', () {
test('once the EQ was available, no state published across a station '
'change and a car toggle has zero custom actions', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
// The EQ action is on the car's now-playing screen before the station
// changes — that is the state the driver is looking at.
await handler.setEcualizadorActivo(true);
final acciones = <int>[];
final sub = handler.playbackState.listen(
(estado) => acciones.add(
estado.controls.where((c) => c.customAction != null).length,
),
);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
// The car tap that used to land inside the window `_recrearPlayer`
// opened. It republishes the controls from `_eqDisponible`, so a flag
// reset to `false` shows up here as an EQ button that disappeared.
await handler.customAction(accionEqToggle);
await sub.cancel();
expect(
acciones,
isNotEmpty,
reason: 'the station change must publish at least one state',
);
expect(
acciones.every((n) => n > 0),
isTrue,
reason:
'the EQ button vanished and reappeared on every station change '
'because `_recrearPlayer` reset `_eqDisponible`; availability is '
'a DEVICE property and does not change with the station. Got '
'$acciones',
);
});
test('the availability flag survives the player rebuild, so a car toggle '
'inside the window still reaches the native effect', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
expect(
handler.ecualizadorDisponible,
isTrue,
reason:
'this is the flag every native EQ path is gated on; false here '
'is exactly the reported «does nothing»',
);
});
});
group('D — a failed native call is traced and never lies', () {
test('a throwing setEnabled is traced instead of swallowed', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.fallosNativosEcualizador,
greaterThan(0),
reason:
'the silent `catch (_) {}` made a dead native equalizer '
'indistinguishable from a working one in a car logcat',
);
});
test('a failed on/off call leaves the published state honest instead of '
'claiming a state the audio does not have', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
handler.ecualizadorActivo,
isFalse,
reason:
'the native effect refused, so the car icon must not read "on" '
'over audio that is not equalized',
);
});
test('a failed on/off call is not persisted', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
escrituras,
isEmpty,
reason:
'persisting a state the device rejected would resurrect it on '
'the next engine start',
);
});
});
}
/// An `AndroidEqualizer` whose `setEnabled` always throws, standing in for a
/// device whose native `Equalizer` effect refuses the call. Nothing else is
/// overridden, so the rest of the handler runs unchanged.
class _EcualizadorQueFalla extends AndroidEqualizer {
@override
Future<void> setEnabled(bool enabled) async {
throw StateError('el efecto nativo rechazo la llamada');
}
}
/// Minimal script/observation record shared by every [_ReproductorFalsoEq]
/// the handler builds (it rebuilds its player on every source change).
class _GuionReproductorEq {
int llamadasSetUrl = 0;
_ReproductorFalsoEq? ultimoReproductor;
}
/// An [AudioPlayer] whose platform-touching methods are replaced, so a real
/// station change can be driven under `flutter test`. Mirrors the double in
/// `servicio_audio_transporte_test.dart`.
class _ReproductorFalsoEq extends AudioPlayer {
_ReproductorFalsoEq(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductorEq _guion;
final _estados = StreamController<PlayerState>.broadcast();
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
return null;
}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}