diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index c262bca..3d15a62 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -507,14 +507,29 @@ class EstadoEcualizador extends ChangeNotifier { return deviceId; } + /// Enables or disables the equalizer. + /// + /// Engine FIRST, disk last. The previous order persisted before telling the + /// engine, so two quick taps raced on a disk write: when the first write + /// resolved last, the engine received the FIRST tap's value after the second + /// one and the checkbox read enabled while the sound stayed flat. Issuing the + /// engine call before any `await` means overlapping taps reach the engine in + /// tap order, so the last tap always wins. + /// + /// Each step then re-checks [_activo]: a newer tap that landed mid-flight + /// owns the outcome, and this superseded call must not apply a preset or + /// persist a value the user has already changed their mind about. Future cambiarActivo(bool activo) async { _activo = activo; - await servicio.guardarActivo(activo); + notifyListeners(); + await audio.setEcualizadorActivo(activo); + if (_activo != activo) return; if (activo) { await audio.aplicarPreset(_presetActual); + if (_activo != activo) return; } - notifyListeners(); + await servicio.guardarActivo(activo); } Future cambiarPreset( diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index b5dd8cb..39179ba 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1543,6 +1543,31 @@ void main() { // builtin_speaker id collision + device removal // --------------------------------------------------------------------------- + group('EstadoEcualizador — cambiarActivo bajo toques rápidos', () { + test('el motor acaba en el estado que muestra la UI', () async { + // cambiarActivo persists BEFORE telling the engine, so two quick taps + // race on a disk write: if the first write resolves last, the engine + // receives the FIRST tap's value after the second one — the checkbox + // says on while the sound is off. + final servicio = _FakeEcualizadorGuardadoLento(); + final audio = FakeServicioAudio(); + final eq = EstadoEcualizador(audio: audio, servicio: servicio); + await eq.cargarPersistido(); + audio.cambiosEcualizadorActivo.clear(); + + final primero = eq.cambiarActivo(false); + final segundo = eq.cambiarActivo(true); + await Future.wait([primero, segundo]); + + expect(eq.activo, isTrue); + expect( + audio.cambiosEcualizadorActivo.last, + isTrue, + reason: 'the engine must end matching the state the UI shows', + ); + }); + }); + group('EstadoEcualizador — bonded Bluetooth names', () { const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; @@ -1704,6 +1729,24 @@ void main() { }); } +/// Fake whose [guardarActivo] stays pending until released, and releases the +/// pending writes in REVERSE order — reproducing a disk write that resolves +/// out of order between two quick taps. +class _FakeEcualizadorGuardadoLento extends FakeServicioEcualizador { + int _llamadas = 0; + + @override + Future guardarActivo(bool activo) async { + // The FIRST write is the slow one: that is the ordering hazard, since an + // unserialized second tap overtakes it and the slow write's engine call + // lands last. + await Future.delayed( + _llamadas++ == 0 ? const Duration(milliseconds: 20) : Duration.zero, + ); + await super.guardarActivo(activo); + } +} + /// Fake whose [resubscribir] stays pending until [completarResubscribir] /// runs — creates the overlap window for the in-flight-guard test above. class _FakeDispositivoAudioResubscribirLento