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
542 lines
18 KiB
Dart
542 lines
18 KiB
Dart
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<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();
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|