- MainActivity: onListen re-emits the current active device and registers the audio device callback idempotently, so recreated activities resync instead of freezing the active-device id on a disconnected device. - servicio_dispositivo_audio: resubscribir() re-opens the event channel; estado_ecualizador exposes refrescarDispositivoActual() with an in-flight guard, invoked on app resume and when opening advanced EQ options, clearing stale green-dot device selections. - navegacion_auto/servicio_audio: new 'Personalizado' browse tree in Android Auto (5 band folders, 13 gain steps each) applied live via setBanda; preset and gain taps persist at device level when multi-device EQ is active and respect station/matrix overrides, with apply-before-persist ordering and children-changed notifications. - l10n: regenerate stale generated localizations; add rxdart as direct dependency for the subscribeToChildren override.
1557 lines
56 KiB
Dart
1557 lines
56 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(
|
||
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();
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
}
|