Files
pluriwave/test/estado/estado_ecualizador_test.dart
T
FreeTLab 4632d53eb8 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
2026-06-27 11:33:53 +02:00

542 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.35.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.55.6: First-seen device copies current preset
// ---------------------------------------------------------------------------
group(
'EstadoEcualizador — first-seen device initialization (Phase 5.55.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.75.8: Toggle-off path uses only 2-level resolution
// ---------------------------------------------------------------------------
group('EstadoEcualizador — toggle-off path (Phase 5.75.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();
},
);
},
);
}