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(); 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 = []; 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 = []; 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 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.broadcast(); @override Stream get playerStateStream => _estados.stream; @override Future setUrl( String url, { Map? headers, Duration? initialPosition, bool preload = true, dynamic tag, }) async { _guion.llamadasSetUrl++; return null; } @override Future play() async {} @override Future pause() async {} @override Future stop() async {} @override Future setVolume(double volume) async {} @override Future dispose() async { await _estados.close(); } }