feat(eq): add per-device equalizer with 4-level preset resolution
Introduce multi-device EQ support allowing each audio output device (built-in speaker, wired, USB, individual Bluetooth by MAC) to have its own equalizer preset, combined with existing per-station presets for a full station×device matrix. - Add DispositivoAudio model and ServicioDispositivoAudio interface - Add Android platform channel (AudioDeviceCallback) for device detection - Add iOS AudioDevicesPlugin (AVAudioSession route tracking) - Extend ServicioEcualizador with device and matrix persistence keys - Implement 4-level resolution: matrix > station > device > global - Add advanced EQ settings section with feature toggle (off by default) - Extend export/import to v3 with backward compatibility - 184 tests passing, zero analyzer issues
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -48,4 +49,493 @@ void main() {
|
||||
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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(avisos, equals(0));
|
||||
expect(fakeAudio.presetsAplicados.length, equals(initialCount));
|
||||
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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
// Clear application history from init; emit device to trigger re-apply
|
||||
fakeAudio.presetsAplicados.clear();
|
||||
fakeDispositivo.emitirDispositivo(btDevice);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(fakeAudio.presetsAplicados.last, equals(matrizPreset));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user