import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/modelos/dispositivo_audio.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import '../helpers/fakes.dart'; void main() { group('EstadoEcualizador (S4-R1)', () { test( 'cambiarPreset notifies EstadoEcualizador listeners (S4-R1-A)', () async { final audio = FakeServicioAudio(); final eq = EstadoEcualizador( audio: audio, servicio: FakeServicioEcualizador(), ); var avisos = 0; eq.addListener(() => avisos++); await eq.cambiarPreset(PresetEcualizador.jazz); expect(avisos, greaterThanOrEqualTo(1)); expect(eq.presetActual, PresetEcualizador.jazz); expect(audio.presetsAplicados, contains(PresetEcualizador.jazz)); eq.dispose(); }, ); test('EQ preset change does NOT rebuild EstadoRadio listeners ' '(S4-R1-A, S4-R5)', () async { final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), iniciarAutomaticamente: false, ); var avisosRadio = 0; var avisosEq = 0; estado.addListener(() => avisosRadio++); estado.ecualizador.addListener(() => avisosEq++); await estado.ecualizador.cambiarPreset(PresetEcualizador.jazz); expect(avisosEq, greaterThanOrEqualTo(1)); expect(avisosRadio, 0); estado.dispose(); }); }); // --------------------------------------------------------------------------- // Phase 5: 4-level resolution hierarchy // --------------------------------------------------------------------------- group('EstadoEcualizador — 4-level resolution (Phase 5)', () { const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'BT Speaker', ); const stationUuid = 'station-uuid-123'; EstadoEcualizador buildEq({ FakeServicioEcualizador? servicio, FakeServicioDispositivoAudio? dispositivoAudio, bool multiDeviceEnabled = true, String? emisoraActualUuid, }) { return EstadoEcualizador( audio: FakeServicioAudio(), servicio: servicio ?? FakeServicioEcualizador(eqMultiDeviceEnabled: multiDeviceEnabled), dispositivoAudio: dispositivoAudio ?? FakeServicioDispositivoAudio(), emisoraActualUuid: () => emisoraActualUuid, ); } // 5.1a — matrix entry wins over all others test( '5.1a matrix entry (stationUuid:deviceId) wins top priority', () async { final matrizPreset = PresetEcualizador.bassBoost; final stationPreset = PresetEcualizador.rock; final devicePreset = PresetEcualizador.jazz; final globalPreset = PresetEcualizador.pop; final eq = buildEq( servicio: FakeServicioEcualizador( principal: globalPreset, porEmisora: {stationUuid: stationPreset}, eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: devicePreset}, presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset}, ), emisoraActualUuid: stationUuid, ); await eq.cargarPersistido(); expect( eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), equals(matrizPreset), ); eq.dispose(); }, ); // 5.1b — falls back to station when no matrix entry test('5.1b falls back to station preset when no matrix entry', () async { final stationPreset = PresetEcualizador.rock; final devicePreset = PresetEcualizador.jazz; final globalPreset = PresetEcualizador.pop; final eq = buildEq( servicio: FakeServicioEcualizador( principal: globalPreset, porEmisora: {stationUuid: stationPreset}, eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: devicePreset}, presetsMatriz: {}, ), emisoraActualUuid: stationUuid, ); await eq.cargarPersistido(); expect( eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), equals(stationPreset), ); eq.dispose(); }); // 5.1c — falls back to device when no station preset test( '5.1c falls back to device preset when no station or matrix entry', () async { final devicePreset = PresetEcualizador.jazz; final globalPreset = PresetEcualizador.pop; final eq = buildEq( servicio: FakeServicioEcualizador( principal: globalPreset, porEmisora: {}, eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: devicePreset}, presetsMatriz: {}, ), emisoraActualUuid: stationUuid, ); await eq.cargarPersistido(); expect( eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), equals(devicePreset), ); eq.dispose(); }, ); // 5.1d — falls back to global when nothing else matches test( '5.1d falls back to global preset when no matrix, station, or device', () async { final globalPreset = PresetEcualizador.pop; final eq = buildEq( servicio: FakeServicioEcualizador( principal: globalPreset, porEmisora: {}, eqMultiDeviceEnabled: true, presetsDispositivo: {}, presetsMatriz: {}, ), emisoraActualUuid: stationUuid, ); await eq.cargarPersistido(); expect( eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), equals(globalPreset), ); eq.dispose(); }, ); // 5.1e — toggle off collapses to 2-level test( '5.1e toggle off: presetEfectivo uses only station → global', () async { final stationPreset = PresetEcualizador.rock; final devicePreset = PresetEcualizador.jazz; final matrizPreset = PresetEcualizador.bassBoost; final eq = buildEq( servicio: FakeServicioEcualizador( principal: PresetEcualizador.flat, porEmisora: {stationUuid: stationPreset}, eqMultiDeviceEnabled: false, presetsDispositivo: {btDevice.id: devicePreset}, presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset}, ), multiDeviceEnabled: false, emisoraActualUuid: stationUuid, ); await eq.cargarPersistido(); // When toggle is off, device and matrix entries must be ignored expect( eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), equals(stationPreset), ); eq.dispose(); }, ); // 5.1f — toggle off with no station falls back to global test('5.1f toggle off with no station: falls back to global', () async { final devicePreset = PresetEcualizador.jazz; final eq = buildEq( servicio: FakeServicioEcualizador( principal: PresetEcualizador.flat, porEmisora: {}, eqMultiDeviceEnabled: false, presetsDispositivo: {btDevice.id: devicePreset}, presetsMatriz: {}, ), multiDeviceEnabled: false, emisoraActualUuid: stationUuid, ); await eq.cargarPersistido(); expect( eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), equals(PresetEcualizador.flat), ); eq.dispose(); }); }); // --------------------------------------------------------------------------- // Phase 5.3: Device stream subscription triggers re-resolution // --------------------------------------------------------------------------- group('EstadoEcualizador — device stream subscription (Phase 5.3–5.4)', () { const stationUuid = 'station-uuid-stream'; const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'BT Speaker', ); test('5.3a device change emits notifyListeners', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: PresetEcualizador.jazz}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); fakeDispositivo.emitirDispositivo(btDevice); // Wait for async processing await Future.delayed(const Duration(milliseconds: 50)); expect(avisos, greaterThanOrEqualTo(1)); eq.dispose(); }); test( '5.3b device change re-applies resolved preset to audio engine', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio(); final btPreset = PresetEcualizador.jazz; final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: btPreset}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); final initialCount = fakeAudio.presetsAplicados.length; fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); expect(fakeAudio.presetsAplicados.length, greaterThan(initialCount)); expect(fakeAudio.presetsAplicados.last, equals(btPreset)); eq.dispose(); }, ); test( '5.3c toggle off: device stream events do NOT trigger any action', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); final initialCount = fakeAudio.presetsAplicados.length; fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); expect(avisos, equals(0)); expect(fakeAudio.presetsAplicados.length, equals(initialCount)); eq.dispose(); }, ); }); // --------------------------------------------------------------------------- // Phase D: device disconnect / cold-start revert // --------------------------------------------------------------------------- group('device disconnect / cold-start revert (Phase D)', () { const stationUuid = 'station-uuid-disconnect'; const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'BT Speaker', ); const builtinSpeaker = DispositivoAudio( id: 'builtin_speaker', tipo: TipoDispositivo.altavozInterno, nombre: 'Speaker', ); test( 'D.1 disconnect resolves and applies the base (builtin_speaker) preset', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( principal: PresetEcualizador.flat, eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: PresetEcualizador.bassBoost}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); // Connect BT first — active device now resolves to its own preset. fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); expect(eq.presetActual, equals(PresetEcualizador.bassBoost)); // Disconnect: platform emits the fallback device (builtin_speaker). fakeDispositivo.emitirDispositivo(builtinSpeaker); await Future.delayed(const Duration(milliseconds: 50)); // builtin_speaker has no station/matrix/device entry -> global (flat). expect(eq.presetActual, equals(PresetEcualizador.flat)); expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.flat)); eq.dispose(); }, ); test( 'D.2 disconnect-created builtin_speaker entry must not mask a later ' 'global preset change (bootstrap-skip proof, not a value tautology)', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( principal: PresetEcualizador.rock, eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: PresetEcualizador.jazz}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); // BT active with its own preset (jazz). fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); // Disconnect to builtin_speaker (never seen before): first-seen // bootstrap must NOT persist an entry for it, so a subsequent global // change is never masked by a stale L3 copy. fakeDispositivo.emitirDispositivo(builtinSpeaker); await Future.delayed(const Duration(milliseconds: 50)); // Change the global preset after the disconnect. await eq.cambiarPresetPrincipal(PresetEcualizador.pop); // Re-trigger resolution for builtin_speaker (e.g. another // disconnect/device-changed event). If bootstrap wrote an entry on // the first emit above, this second emit would resolve the STALE // value (rock) instead of the fresh global (pop) — proving the // masking bug. This assertion only passes when no L3 entry for // builtin_speaker was ever created. fakeDispositivo.emitirDispositivo(builtinSpeaker); await Future.delayed(const Duration(milliseconds: 50)); expect(eq.presetsDispositivo.containsKey('builtin_speaker'), isFalse); expect(eq.presetActual, equals(PresetEcualizador.pop)); expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.pop)); eq.dispose(); }, ); test( 'D.3 cold start with no device connected applies base resolution, ' 'never a stale persisted device preset', () async { final fakeAudio = FakeServicioAudio(); // No emitirDispositivo call: obtenerDispositivoActual() defaults to // builtin_speaker, simulating a cold start with nothing connected. final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( principal: PresetEcualizador.rock, eqMultiDeviceEnabled: true, // Stale entry for a BT device that is NOT currently connected. presetsDispositivo: {btDevice.id: PresetEcualizador.jazz}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); expect(eq.dispositivoActualId, equals('builtin_speaker')); expect(eq.presetActual, equals(PresetEcualizador.rock)); expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.rock)); eq.dispose(); }, ); test( 'D.4 connect -> disconnect -> reconnect cycle lands on the correct ' 'preset at each step and never corrupts the device entry', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( principal: PresetEcualizador.flat, eqMultiDeviceEnabled: true, presetsMatriz: {'$stationUuid:${btDevice.id}': PresetEcualizador.bassBoost}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); // Connect: matrix override wins. BT is never-seen, so its own // first-seen bootstrap fires here too (unrelated to this change -- // only builtin_speaker is excluded from bootstrap). fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); expect(eq.presetActual, equals(PresetEcualizador.bassBoost)); expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.bassBoost)); final btPresetTrasConectar = eq.presetsDispositivo[btDevice.id]; expect(btPresetTrasConectar, isNotNull); // Disconnect: builtin_speaker has no matrix/station/device entry -> global. fakeDispositivo.emitirDispositivo(builtinSpeaker); await Future.delayed(const Duration(milliseconds: 50)); expect(eq.presetActual, equals(PresetEcualizador.flat)); expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.flat)); // Reconnect: matrix override wins again. fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); expect(eq.presetActual, equals(PresetEcualizador.bassBoost)); expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.bassBoost)); // The disconnect step must never have touched the BT device's own // entry (locks existing test 5.5b: reconnect must not overwrite it). expect( eq.presetsDispositivo[btDevice.id], equals(btPresetTrasConectar), ); eq.dispose(); }, ); test( 'D.5 station override still wins over global on disconnect-to-base', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( principal: PresetEcualizador.flat, porEmisora: {stationUuid: PresetEcualizador.rock}, eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: PresetEcualizador.jazz}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); // BT active. Note the station override (Level 2) already applies to // every device on this station, including BT -- resolution is rock // regardless of BT's own Level-3 entry, which is expected hierarchy // behavior (Level 2 beats Level 3) and not itself under test here. fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); // Disconnect to builtin_speaker: station override (Level 2) must // still beat global (Level 4) -- deterministic revert, not a flatten // to global. fakeDispositivo.emitirDispositivo(builtinSpeaker); await Future.delayed(const Duration(milliseconds: 50)); expect(eq.presetActual, equals(PresetEcualizador.rock)); expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.rock)); eq.dispose(); }, ); }); // --------------------------------------------------------------------------- // Phase 5.5–5.6: First-seen device copies current preset // --------------------------------------------------------------------------- group( 'EstadoEcualizador — first-seen device initialization (Phase 5.5–5.6)', () { const stationUuid = 'station-uuid-first-seen'; const newDevice = DispositivoAudio( id: 'bt_a2dp:NEW:DE:VI:CE:00:00', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'New BT Device', ); test('5.5a new device gets copy of current resolved preset', () async { final fakeServicio = FakeServicioEcualizador( principal: PresetEcualizador.rock, eqMultiDeviceEnabled: true, presetsDispositivo: {}, ); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: fakeServicio, dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); fakeDispositivo.emitirDispositivo(newDevice); await Future.delayed(const Duration(milliseconds: 50)); // The device should now have a preset saved equal to the resolved preset // at the time of first connection (rock — global fallback) expect( eq.presetsDispositivo[newDevice.id], equals(PresetEcualizador.rock), ); eq.dispose(); }); test( '5.5b subsequent reconnection does NOT overwrite user-saved preset', () async { final userSavedPreset = PresetEcualizador.jazz; final fakeServicio = FakeServicioEcualizador( principal: PresetEcualizador.rock, eqMultiDeviceEnabled: true, presetsDispositivo: {newDevice.id: userSavedPreset}, ); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: fakeServicio, dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); fakeDispositivo.emitirDispositivo(newDevice); await Future.delayed(const Duration(milliseconds: 50)); // Preset must remain the user-saved one, not overwritten with rock expect(eq.presetsDispositivo[newDevice.id], equals(userSavedPreset)); eq.dispose(); }, ); }, ); // --------------------------------------------------------------------------- // Phase 5.7–5.8: Toggle-off path uses only 2-level resolution // --------------------------------------------------------------------------- group('EstadoEcualizador — toggle-off path (Phase 5.7–5.8)', () { const stationUuid = 'station-toggle-off'; const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'BT Speaker', ); test( '5.7a cargarPersistido with toggle off applies 2-level resolution', () async { final fakeAudio = FakeServicioAudio(); final stationPreset = PresetEcualizador.rock; final devicePreset = PresetEcualizador.jazz; final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( principal: PresetEcualizador.flat, porEmisora: {stationUuid: stationPreset}, eqMultiDeviceEnabled: false, presetsDispositivo: {btDevice.id: devicePreset}, ), dispositivoAudio: FakeServicioDispositivoAudio(), emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); // With toggle off, no device subscription, direct 2-level on presetEfectivo expect( eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id), equals(stationPreset), ); eq.dispose(); }, ); test('5.8a eqMultiDeviceEnabled getter reflects loaded value', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); expect(eq.eqMultiDeviceEnabled, isTrue); eq.dispose(); }); test('5.8b toggle defaults to false and eq behaves as before', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); expect(eq.eqMultiDeviceEnabled, isFalse); eq.dispose(); }); test( '5.8c cambiarMultiDeviceEnabled persists toggle and notifies', () async { final fakeServicio = FakeServicioEcualizador( eqMultiDeviceEnabled: false, ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: fakeServicio, dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); await eq.cambiarMultiDeviceEnabled(true); expect(eq.eqMultiDeviceEnabled, isTrue); expect(fakeServicio.config.eqMultiDeviceEnabled, isTrue); expect(avisos, greaterThanOrEqualTo(1)); eq.dispose(); }, ); }); // --------------------------------------------------------------------------- // Phase 5.9: REFACTOR — aplicarPresetActivo path applies device-resolved preset // --------------------------------------------------------------------------- group( 'EstadoEcualizador — aplicarPresetActivo uses device resolution (5.9)', () { const stationUuid = 'station-refactor'; const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'BT Speaker', ); test( '5.9a aplicarPresetEmisoraActual uses device-resolved preset when toggle on', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio()..emitirDispositivo(btDevice); final matrizPreset = PresetEcualizador.bassBoost; final eq = EstadoEcualizador( audio: fakeAudio, servicio: FakeServicioEcualizador( eqMultiDeviceEnabled: true, presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); await Future.delayed(const Duration(milliseconds: 50)); // Clear application history from init; emit device to trigger re-apply fakeAudio.presetsAplicados.clear(); fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); expect(fakeAudio.presetsAplicados.last, equals(matrizPreset)); eq.dispose(); }, ); }, ); // --------------------------------------------------------------------------- // eq-device-autoswitch-ux Phase 2: startup seeding + rename API // --------------------------------------------------------------------------- group('EstadoEcualizador — startup device seeding (eq-device-autoswitch-ux Phase 2)', () { const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'BT Speaker', ); // 2.1 RED — after cargarPersistido with multiDevice ON, dispositivoActualId is seeded test('2.1 cargarPersistido seeds dispositivoActualId from obtenerDispositivoActual', () async { final fakeDispositivo = FakeServicioDispositivoAudio() ..emitirDispositivo(btDevice); // sets _dispositivoActual final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, ); await eq.cargarPersistido(); expect(eq.dispositivoActualId, equals('bt_a2dp:AA:BB')); eq.dispose(); }); // 2.2 RED — first-seen device is bootstrapped from resolved preset at startup test('2.2 first-seen device is bootstrapped at startup with resolved preset', () async { final fakeServicio = FakeServicioEcualizador( principal: PresetEcualizador.rock, eqMultiDeviceEnabled: true, presetsDispositivo: {}, ); final fakeDispositivo = FakeServicioDispositivoAudio() ..emitirDispositivo(btDevice); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: fakeServicio, dispositivoAudio: fakeDispositivo, ); await eq.cargarPersistido(); expect( eq.presetsDispositivo[btDevice.id], equals(PresetEcualizador.rock), ); eq.dispose(); }); // 2.3 RED — obtenerDispositivoActual throws → fallback to 'builtin_speaker' test('2.3 obtenerDispositivoActual throws → dispositivoActualId falls back to builtin_speaker', () async { final fakeDispositivo = FakeServicioDispositivoAudioThrows(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, ); await eq.cargarPersistido(); // must not throw expect(eq.dispositivoActualId, equals('builtin_speaker')); eq.dispose(); }); // 2.4 RED — toggle off → obtenerDispositivoActual is never called test('2.4 eqMultiDeviceEnabled=false → obtenerDispositivoActual NOT called', () async { final fakeDispositivo = FakeServicioDispositivoAudioThrows(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false), dispositivoAudio: fakeDispositivo, ); // If obtenerDispositivoActual were called it would throw → test would fail await expectLater(eq.cargarPersistido(), completes); expect(eq.dispositivoActualId, isNull); eq.dispose(); }); }); group('EstadoEcualizador — rename API (eq-device-autoswitch-ux Phase 2)', () { // 2.6 RED — renombrarDispositivo writes to _nombresDispositivos and notifies test('2.6a renombrarDispositivo stores name and notifyListeners', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'Living Room BT'); expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('Living Room BT')); expect(avisos, greaterThanOrEqualTo(1)); eq.dispose(); }); // 2.6 RED — empty name is no-op (preserves existing name) test('2.6b renombrarDispositivo with empty string is no-op', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Headset'); await eq.renombrarDispositivo('bt_a2dp:AA:BB', ''); expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('My Headset')); eq.dispose(); }); // 2.7 RED — obtenerNombreDispositivo returns empty string for unknown device test('2.7 obtenerNombreDispositivo returns empty string for unknown device', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); expect(eq.obtenerNombreDispositivo('unknown_device'), equals('')); eq.dispose(); }); // 2.8 RED — nombreVisible fallback chain: custom > platform > id test('2.8a nombreVisible returns custom name when available', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'Studio Monitors'); expect( eq.nombreVisible('bt_a2dp:AA:BB', 'USB Audio'), equals('Studio Monitors'), ); eq.dispose(); }); test('2.8b nombreVisible returns platform name when no custom name', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); expect( eq.nombreVisible('bt_a2dp:AA:BB', 'Sony WH-1000XM5'), equals('Sony WH-1000XM5'), ); eq.dispose(); }); test('2.8c nombreVisible returns raw deviceId when no custom and no platform name', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); expect(eq.nombreVisible('bt_a2dp:AA:BB', ''), equals('bt_a2dp:AA:BB')); eq.dispose(); }); // nombresDispositivos getter test('2.9 nombresDispositivos getter returns current map', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My BT'); expect(eq.nombresDispositivos['bt_a2dp:AA:BB'], equals('My BT')); eq.dispose(); }); // 2.10 RED — _nombresDispositivos loaded from ConfiguracionEcualizador.nombresDispositivos test('2.10 cargarPersistido loads nombresDispositivos from config', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador( nombresDispositivos: {'bt_a2dp:AA:BB': 'Loaded Name'}, ), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('Loaded Name')); eq.dispose(); }); }); // --------------------------------------------------------------------------- // CRITICAL-1: renombrarDispositivo is a no-op when toggle is OFF // --------------------------------------------------------------------------- group('EstadoEcualizador — renombrarDispositivo toggle guard (CRITICAL-1)', () { test( 'renombrarDispositivo is no-op when eqMultiDeviceEnabled is false', () async { final fakeServicio = FakeServicioEcualizador(eqMultiDeviceEnabled: false); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: fakeServicio, dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Device'); // No name stored, no listeners notified, service not called expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('')); expect(avisos, equals(0)); expect(fakeServicio.config.nombresDispositivos, isEmpty); eq.dispose(); }, ); test( 'renombrarDispositivo works normally when eqMultiDeviceEnabled is true', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); await eq.renombrarDispositivo('bt_a2dp:AA:BB', 'My Device'); expect(eq.obtenerNombreDispositivo('bt_a2dp:AA:BB'), equals('My Device')); expect(avisos, greaterThanOrEqualTo(1)); eq.dispose(); }, ); }); // --------------------------------------------------------------------------- // CRITICAL-2: guardarPresetDispositivo method // --------------------------------------------------------------------------- group('EstadoEcualizador — guardarPresetDispositivo (CRITICAL-2)', () { const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'BT Speaker', ); test( 'guardarPresetDispositivo stores preset and notifies listeners', () async { final fakeServicio = FakeServicioEcualizador( eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: PresetEcualizador.flat}, ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: fakeServicio, dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.jazz); expect(eq.presetsDispositivo[btDevice.id], equals(PresetEcualizador.jazz)); expect(fakeServicio.config.presetsDispositivo[btDevice.id], equals(PresetEcualizador.jazz)); expect(avisos, greaterThanOrEqualTo(1)); eq.dispose(); }, ); test( 'guardarPresetDispositivo is no-op when toggle is OFF', () async { final fakeServicio = FakeServicioEcualizador( eqMultiDeviceEnabled: false, presetsDispositivo: {btDevice.id: PresetEcualizador.flat}, ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: fakeServicio, dispositivoAudio: FakeServicioDispositivoAudio(), ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.jazz); // Preset must remain unchanged expect(eq.presetsDispositivo[btDevice.id], equals(PresetEcualizador.flat)); expect(avisos, equals(0)); eq.dispose(); }, ); test( 'guardarPresetDispositivo triggers re-resolve and apply when device is active', () async { final fakeAudio = FakeServicioAudio(); final fakeDispositivo = FakeServicioDispositivoAudio() ..emitirDispositivo(btDevice); const stationUuid = 'station-save-test'; final fakeServicio = FakeServicioEcualizador( eqMultiDeviceEnabled: true, presetsDispositivo: {btDevice.id: PresetEcualizador.flat}, ); final eq = EstadoEcualizador( audio: fakeAudio, servicio: fakeServicio, dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); await Future.delayed(const Duration(milliseconds: 50)); fakeAudio.presetsAplicados.clear(); await eq.guardarPresetDispositivo(btDevice.id, PresetEcualizador.rock); // Audio was re-applied (re-resolve was triggered) expect(fakeAudio.presetsAplicados, isNotEmpty); // The resolved preset is the device preset (level 3, no station preset set) expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.rock)); eq.dispose(); }, ); test( 'guardarPresetDispositivo does NOT re-apply when device is not active', () async { final fakeAudio = FakeServicioAudio(); final fakeServicio = FakeServicioEcualizador( eqMultiDeviceEnabled: true, presetsDispositivo: { btDevice.id: PresetEcualizador.flat, 'other_device': PresetEcualizador.flat, }, ); final fakeDispositivo = FakeServicioDispositivoAudio() ..emitirDispositivo(btDevice); final eq = EstadoEcualizador( audio: fakeAudio, servicio: fakeServicio, dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); await Future.delayed(const Duration(milliseconds: 50)); // Active device is btDevice; save for 'other_device' fakeAudio.presetsAplicados.clear(); await eq.guardarPresetDispositivo('other_device', PresetEcualizador.rock); // Preset saved but audio NOT re-applied since 'other_device' is not active expect(eq.presetsDispositivo['other_device'], equals(PresetEcualizador.rock)); expect(fakeAudio.presetsAplicados, isEmpty); eq.dispose(); }, ); }); // --------------------------------------------------------------------------- // WARNING-3: FakeServicioEcualizador preserves nombresDispositivos // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // bt-device-identity Phase 3: platform-name cache + duplicate-entry guard // --------------------------------------------------------------------------- group('EstadoEcualizador — platform-name cache (bt-device-identity Phase 3)', () { const stationUuid = 'station-uuid-bt-identity'; test('3.1 platform name is cached from a device-change event', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); fakeDispositivo.emitirDispositivo( const DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'AirPods Pro', ), ); await Future.delayed(const Duration(milliseconds: 50)); expect( eq.nombrePlataforma('bt_a2dp:AA:BB:CC:DD:EE:FF'), equals('AirPods Pro'), ); eq.dispose(); }); test( '3.4 composite-placeholder sentinel does not create device-list entry', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); fakeDispositivo.emitirDispositivo( const DispositivoAudio( id: 'bt_a2dp:name:AirPods-Pro', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'AirPods Pro', ), ); await Future.delayed(const Duration(milliseconds: 50)); expect( eq.presetsDispositivo.containsKey('bt_a2dp:name:AirPods-Pro'), isFalse, ); expect( eq.nombrePlataforma('bt_a2dp:name:AirPods-Pro'), equals('AirPods Pro'), ); eq.dispose(); }, ); test('3.6 multiple denied-permission devices do not collide', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); final baseline = eq.presetsDispositivo.length; fakeDispositivo.emitirDispositivo( const DispositivoAudio( id: 'bt_a2dp:name:AirPods-Pro', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'AirPods Pro', ), ); await Future.delayed(const Duration(milliseconds: 50)); fakeDispositivo.emitirDispositivo( const DispositivoAudio( id: 'bt_a2dp:name:Sony-WH-1000XM5', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'Sony WH-1000XM5', ), ); await Future.delayed(const Duration(milliseconds: 50)); expect( eq.nombrePlataforma('bt_a2dp:name:AirPods-Pro'), equals('AirPods Pro'), ); expect( eq.nombrePlataforma('bt_a2dp:name:Sony-WH-1000XM5'), equals('Sony WH-1000XM5'), ); expect(eq.presetsDispositivo.length, equals(baseline)); eq.dispose(); }); test( '3.7 repeated event for known id is a no-op on preset creation ' '(regression-lock)', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); const device = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'AirPods Pro', ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); fakeDispositivo.emitirDispositivo(device); await Future.delayed(const Duration(milliseconds: 50)); final presetTrasPrimerEvento = eq.presetsDispositivo[device.id]; final cantidadTrasPrimerEvento = eq.presetsDispositivo.length; expect(presetTrasPrimerEvento, isNotNull); fakeDispositivo.emitirDispositivo(device); await Future.delayed(const Duration(milliseconds: 50)); expect( eq.presetsDispositivo[device.id], equals(presetTrasPrimerEvento), ); expect(eq.presetsDispositivo.length, equals(cantidadTrasPrimerEvento)); eq.dispose(); }, ); test( '3.8 transient non-BT id during pairing handshake does not corrupt ' 'the BT entry (regression-lock)', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'AirPods Pro', ); const builtinSpeaker = DispositivoAudio( id: 'builtin_speaker', tipo: TipoDispositivo.altavozInterno, nombre: 'Speaker', ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador( principal: PresetEcualizador.rock, eqMultiDeviceEnabled: true, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); // Transient builtin_speaker report mid-handshake. fakeDispositivo.emitirDispositivo(builtinSpeaker); await Future.delayed(const Duration(milliseconds: 50)); // Final BT event once the A2DP profile attaches. fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); expect(eq.presetsDispositivo.containsKey(btDevice.id), isTrue); expect(eq.presetsDispositivo.containsKey('builtin_speaker'), isFalse); expect( eq.presetsDispositivo[btDevice.id], equals(PresetEcualizador.rock), ); eq.dispose(); }, ); test( '3.10 rename persists after re-pair — composed regression ' '(closes bt-device-identity CRITICAL-1)', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'AirPods Pro', ); const builtinSpeaker = DispositivoAudio( id: 'builtin_speaker', tipo: TipoDispositivo.altavozInterno, nombre: 'Speaker', ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); // Connect: real-MAC id, first-seen bootstrap creates a preset entry. fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); final presetTrasConectar = eq.presetsDispositivo[btDevice.id]; expect(presetTrasConectar, isNotNull); // Rename the device via the in-app edit dialog path. await eq.renombrarDispositivo(btDevice.id, 'My Headphones'); expect( eq.nombreVisible(btDevice.id, eq.nombrePlataforma(btDevice.id)), equals('My Headphones'), ); final cantidadTrasRenombrar = eq.presetsDispositivo.length; // Disconnect: platform reports the transient builtin_speaker fallback. fakeDispositivo.emitirDispositivo(builtinSpeaker); await Future.delayed(const Duration(milliseconds: 50)); // Re-pair: same physical device reconnects, reporting the SAME MAC. fakeDispositivo.emitirDispositivo(btDevice); await Future.delayed(const Duration(milliseconds: 50)); // No duplicate entry: presetsDispositivo did not grow. expect(eq.presetsDispositivo.length, equals(cantidadTrasRenombrar)); // The device's own preset entry survives untouched (not overwritten). expect(eq.presetsDispositivo[btDevice.id], equals(presetTrasConectar)); // Custom rename still wins over the re-cached platform name. expect( eq.nombreVisible(btDevice.id, eq.nombrePlataforma(btDevice.id)), equals('My Headphones'), ); eq.dispose(); }, ); }); group('FakeServicioEcualizador — nombresDispositivos preservation (WARNING-3)', () { test( 'guardarPrincipal preserves nombresDispositivos', () async { final fake = FakeServicioEcualizador( nombresDispositivos: {'dev1': 'My Speaker'}, ); await fake.guardarPrincipal(PresetEcualizador.jazz); expect(fake.config.nombresDispositivos['dev1'], equals('My Speaker')); }, ); test( 'guardarActivo preserves nombresDispositivos', () async { final fake = FakeServicioEcualizador( nombresDispositivos: {'dev1': 'My Speaker'}, ); await fake.guardarActivo(false); expect(fake.config.nombresDispositivos['dev1'], equals('My Speaker')); }, ); }); // --------------------------------------------------------------------------- // Fix: robust active-device detection (stale green dot) // --------------------------------------------------------------------------- group('EstadoEcualizador.refrescarDispositivoActual', () { const btDevice = DispositivoAudio( id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', tipo: TipoDispositivo.bluetoothA2dp, nombre: 'BT Speaker', ); test( 'resubscribes the event channel and re-seeds the id from a fresh ' 'device query', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); expect(eq.dispositivoActualId, equals('builtin_speaker')); // The native side changed device but the stale subscription lost the // event (activity recreation): only the fake's cached value moved. fakeDispositivo.establecerDispositivoSinEvento(btDevice); await eq.refrescarDispositivoActual(); expect(fakeDispositivo.resubscribirLlamadas, equals(1)); expect(eq.dispositivoActualId, equals(btDevice.id)); eq.dispose(); }, ); test('is a no-op when the multi-device toggle is off', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); await eq.refrescarDispositivoActual(); expect(fakeDispositivo.resubscribirLlamadas, equals(0)); expect(eq.dispositivoActualId, isNull); eq.dispose(); }); test('is a no-op when no device service is injected', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), emisoraActualUuid: () => null, ); await eq.cargarPersistido(); // Must not throw despite the missing service. await eq.refrescarDispositivoActual(); expect(eq.dispositivoActualId, isNull); eq.dispose(); }); test('is safe to call repeatedly', () async { final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); fakeDispositivo.establecerDispositivoSinEvento(btDevice); await eq.refrescarDispositivoActual(); await eq.refrescarDispositivoActual(); await eq.refrescarDispositivoActual(); expect(fakeDispositivo.resubscribirLlamadas, equals(3)); expect(eq.dispositivoActualId, equals(btDevice.id)); eq.dispose(); }); test( 'a failing resubscribe still falls back to builtin_speaker via the ' 'seeding error path instead of throwing', () async { final fakeDispositivo = FakeServicioDispositivoAudioThrows(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); await eq.refrescarDispositivoActual(); expect(fakeDispositivo.resubscribirLlamadas, equals(1)); expect(eq.dispositivoActualId, equals('builtin_speaker')); eq.dispose(); }, ); test( 'overlapping calls share one in-flight refresh (single resubscribe)', () async { final fakeDispositivo = _FakeDispositivoAudioResubscribirLento(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); // Second call lands while the first is still awaiting resubscribir. final primero = eq.refrescarDispositivoActual(); final segundo = eq.refrescarDispositivoActual(); fakeDispositivo.completarResubscribir(); await Future.wait([primero, segundo]); expect(fakeDispositivo.resubscribirLlamadas, equals(1)); eq.dispose(); }, ); }); // --------------------------------------------------------------------------- // 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'; test('a paired but disconnected device resolves its system name', () async { final fakeDispositivo = FakeServicioDispositivoAudio( nombresEmparejados: {'AA:BB:CC:DD:EE:FF': 'Omoda'}, ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador( eqMultiDeviceEnabled: true, presetsDispositivo: {deviceId: PresetEcualizador.rock}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); // The device was never seen on the stream this session, so the only // possible source is the system's bonded-device list. expect(eq.nombrePlataforma(deviceId), equals('Omoda')); eq.dispose(); }); test('a live enumeration name wins over the bonded one', () async { final fakeDispositivo = FakeServicioDispositivoAudio( nombresEmparejados: {'AA:BB:CC:DD:EE:FF': 'Stale pairing name'}, ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); fakeDispositivo.emitirDispositivo( const DispositivoAudio( id: deviceId, tipo: TipoDispositivo.bluetoothA2dp, nombre: 'Omoda', ), ); await Future.delayed(Duration.zero); expect(eq.nombrePlataforma(deviceId), equals('Omoda')); eq.dispose(); }); test('a custom name still beats the bonded one', () async { final fakeDispositivo = FakeServicioDispositivoAudio( nombresEmparejados: {'AA:BB:CC:DD:EE:FF': 'OMODA 5'}, ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador( eqMultiDeviceEnabled: true, nombresDispositivos: {deviceId: 'Coche'}, ), dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); expect( eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)), equals('Coche'), ); eq.dispose(); }); test('a failing bonded-name lookup never breaks startup', () async { final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), dispositivoAudio: FakeServicioDispositivoAudioThrows(), emisoraActualUuid: () => null, ); await eq.cargarPersistido(); expect(eq.nombrePlataforma('bt_a2dp:AA:BB'), isEmpty); eq.dispose(); }); }); group('EstadoEcualizador — builtin_speaker collision guard', () { test( 'an unknown-type device reported under the base id creates no entry', () async { final servicio = FakeServicioEcualizador(eqMultiDeviceEnabled: true); final fakeDispositivo = FakeServicioDispositivoAudio(); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: servicio, dispositivoAudio: fakeDispositivo, emisoraActualUuid: () => null, ); await eq.cargarPersistido(); // What the old native else-branch emitted for an LE Audio car stereo: // the phone-speaker id carrying a type Dart maps to `desconocido`. fakeDispositivo.emitirDispositivo( const DispositivoAudio( id: 'builtin_speaker', tipo: TipoDispositivo.desconocido, nombre: 'Omoda', ), ); await Future.delayed(Duration.zero); expect(eq.presetsDispositivo.containsKey('builtin_speaker'), isFalse); eq.dispose(); }, ); test('eliminarDispositivo clears preset, name and matrix entries', () async { const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; const stationUuid = 'station-uuid-123'; const otroId = 'bt_a2dp:11:22:33:44:55:66'; final servicio = FakeServicioEcualizador( eqMultiDeviceEnabled: true, presetsDispositivo: { deviceId: PresetEcualizador.jazz, otroId: PresetEcualizador.rock, }, presetsMatriz: { '$stationUuid:$deviceId': PresetEcualizador.bassBoost, '$stationUuid:$otroId': PresetEcualizador.pop, }, nombresDispositivos: {deviceId: 'Omoda', otroId: 'Travel headphones'}, ); final eq = EstadoEcualizador( audio: FakeServicioAudio(), servicio: servicio, dispositivoAudio: FakeServicioDispositivoAudio(), emisoraActualUuid: () => stationUuid, ); await eq.cargarPersistido(); var avisos = 0; eq.addListener(() => avisos++); await eq.eliminarDispositivo(deviceId); expect(eq.presetsDispositivo.containsKey(deviceId), isFalse); expect(eq.nombresDispositivos.containsKey(deviceId), isFalse); expect(eq.presetsMatriz.containsKey('$stationUuid:$deviceId'), isFalse); // Sibling device untouched. expect(eq.presetsDispositivo[otroId], equals(PresetEcualizador.rock)); expect(eq.nombresDispositivos[otroId], equals('Travel headphones')); expect( eq.presetsMatriz['$stationUuid:$otroId'], equals(PresetEcualizador.pop), ); expect(avisos, greaterThanOrEqualTo(1)); eq.dispose(); }); }); } /// 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 extends FakeServicioDispositivoAudio { final _resubscribir = Completer(); void completarResubscribir() => _resubscribir.complete(); @override Future resubscribir() async { resubscribirLlamadas++; await _resubscribir.future; } }