A toggle from the Android Auto notification or a preset picked from the car's EQ folder mutated PluriWaveAudioHandler state directly, leaving EstadoEcualizador (and therefore the phone UI) unaware and never persisting the change, so it was lost on the next app restart. Forward the handler's ecualizadorActivo flag through ServicioAudio and, mirroring EstadoRadio's existing playFromMediaId resync, diff it plus presetActual against the cached values on every estadoStream tick, adopting and persisting a divergence via ServicioEcualizador.
1891 lines
68 KiB
Dart
1891 lines
68 KiB
Dart
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(
|
||
esPremium: () => true,
|
||
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 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<void>.delayed(const Duration(milliseconds: 50));
|
||
expect(eq.presetActual, equals(PresetEcualizador.bassBoost));
|
||
|
||
// Disconnect: platform emits the fallback device (builtin_speaker).
|
||
fakeDispositivo.emitirDispositivo(builtinSpeaker);
|
||
await Future<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<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();
|
||
},
|
||
);
|
||
},
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 50));
|
||
final presetTrasPrimerEvento = eq.presetsDispositivo[device.id];
|
||
final cantidadTrasPrimerEvento = eq.presetsDispositivo.length;
|
||
expect(presetTrasPrimerEvento, isNotNull);
|
||
|
||
fakeDispositivo.emitirDispositivo(device);
|
||
await Future<void>.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<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
// Final BT event once the A2DP profile attaches.
|
||
fakeDispositivo.emitirDispositivo(btDevice);
|
||
await Future<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
// Re-pair: same physical device reconnects, reporting the SAME MAC.
|
||
fakeDispositivo.emitirDispositivo(btDevice);
|
||
await Future<void>.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<void>.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<void>.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();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// eq-sync-superficies: car/notification-initiated EQ changes must reach
|
||
// EstadoEcualizador (and persist through ServicioEcualizador), not just
|
||
// the audio handler.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
group('EstadoEcualizador — resync with handler-initiated EQ changes '
|
||
'(eq-sync-superficies)', () {
|
||
test(
|
||
'a handler-initiated toggle (car/notification) syncs activo and '
|
||
'notifies listeners',
|
||
() async {
|
||
final fakeAudio = FakeServicioAudio();
|
||
final eq = EstadoEcualizador(
|
||
audio: fakeAudio,
|
||
servicio: FakeServicioEcualizador(activo: true),
|
||
);
|
||
await eq.cargarPersistido();
|
||
expect(eq.activo, isTrue);
|
||
|
||
var avisos = 0;
|
||
eq.addListener(() => avisos++);
|
||
|
||
// Simulates `accionEqToggle` calling
|
||
// `PluriWaveAudioHandler.setEcualizadorActivo` directly, bypassing
|
||
// `ServicioAudio`/`EstadoEcualizador` entirely.
|
||
fakeAudio.simularCambioEqDesdeHandler(activo: false);
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
expect(eq.activo, isFalse);
|
||
expect(avisos, greaterThanOrEqualTo(1));
|
||
eq.dispose();
|
||
},
|
||
);
|
||
|
||
test(
|
||
'a handler-initiated preset change (Android Auto) syncs presetActual '
|
||
'and notifies listeners',
|
||
() async {
|
||
final fakeAudio = FakeServicioAudio();
|
||
final eq = EstadoEcualizador(
|
||
audio: fakeAudio,
|
||
servicio: FakeServicioEcualizador(principal: PresetEcualizador.flat),
|
||
);
|
||
await eq.cargarPersistido();
|
||
expect(eq.presetActual, equals(PresetEcualizador.flat));
|
||
|
||
var avisos = 0;
|
||
eq.addListener(() => avisos++);
|
||
|
||
// Simulates `seleccionarPresetEqPorMediaId` calling
|
||
// `PluriWaveAudioHandler.aplicarPreset` directly.
|
||
fakeAudio.simularCambioEqDesdeHandler(preset: PresetEcualizador.jazz);
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
expect(eq.presetActual, equals(PresetEcualizador.jazz));
|
||
expect(avisos, greaterThanOrEqualTo(1));
|
||
eq.dispose();
|
||
},
|
||
);
|
||
|
||
test(
|
||
'a handler-initiated toggle is persisted through ServicioEcualizador '
|
||
'(survives a restart)',
|
||
() async {
|
||
final fakeAudio = FakeServicioAudio();
|
||
final fakeServicio = FakeServicioEcualizador(activo: true);
|
||
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
|
||
await eq.cargarPersistido();
|
||
|
||
fakeAudio.simularCambioEqDesdeHandler(activo: false);
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
expect(fakeServicio.config.activo, isFalse);
|
||
expect(fakeServicio.guardarActivoLlamadas, equals(1));
|
||
eq.dispose();
|
||
},
|
||
);
|
||
|
||
test(
|
||
'resync does not cause an extra handler write (no feedback loop)',
|
||
() async {
|
||
final fakeAudio = FakeServicioAudio();
|
||
final eq = EstadoEcualizador(
|
||
audio: fakeAudio,
|
||
servicio: FakeServicioEcualizador(activo: true),
|
||
);
|
||
await eq.cargarPersistido();
|
||
fakeAudio.cambiosEcualizadorActivo.clear();
|
||
fakeAudio.presetsAplicados.clear();
|
||
|
||
fakeAudio.simularCambioEqDesdeHandler(activo: false);
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
// The resync must only read from `audio` and write to `servicio` —
|
||
// never write BACK into `audio`, or a handler write would trigger
|
||
// another stream tick, which would resync again, forever.
|
||
expect(fakeAudio.cambiosEcualizadorActivo, isEmpty);
|
||
expect(fakeAudio.presetsAplicados, isEmpty);
|
||
eq.dispose();
|
||
},
|
||
);
|
||
|
||
test(
|
||
'a UI-initiated toggle still works exactly as before and persists '
|
||
'exactly once (regression)',
|
||
() async {
|
||
final fakeAudio = FakeServicioAudio();
|
||
final fakeServicio = FakeServicioEcualizador(activo: true);
|
||
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
|
||
await eq.cargarPersistido();
|
||
fakeAudio.cambiosEcualizadorActivo.clear();
|
||
|
||
await eq.cambiarActivo(false);
|
||
// Give any (harmless, no-op) resync tick a chance to run too.
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
expect(eq.activo, isFalse);
|
||
expect(fakeServicio.config.activo, isFalse);
|
||
expect(fakeServicio.guardarActivoLlamadas, equals(1));
|
||
expect(fakeAudio.cambiosEcualizadorActivo, equals([false]));
|
||
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<void> 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<void>.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>();
|
||
|
||
void completarResubscribir() => _resubscribir.complete();
|
||
|
||
@override
|
||
Future<void> resubscribir() async {
|
||
resubscribirLlamadas++;
|
||
await _resubscribir.future;
|
||
}
|
||
}
|