feat(eq): android auto custom equalizer and robust device detection
- 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.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
@@ -1402,4 +1404,153 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,11 +433,20 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
||||
@override
|
||||
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
|
||||
|
||||
/// Number of times [resubscribir] was called.
|
||||
int resubscribirLlamadas = 0;
|
||||
|
||||
@override
|
||||
Future<DispositivoAudio> obtenerDispositivoActual() async {
|
||||
throw Exception('Platform channel error: device unavailable');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resubscribir() async {
|
||||
resubscribirLlamadas++;
|
||||
throw Exception('Platform channel error: resubscribe unavailable');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoBluetooth() async => true;
|
||||
|
||||
@@ -462,6 +471,9 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
||||
/// Number of times [solicitarPermisoBluetooth] was called.
|
||||
int solicitarPermisoBluetoothCalls = 0;
|
||||
|
||||
/// Number of times [resubscribir] was called.
|
||||
int resubscribirLlamadas = 0;
|
||||
|
||||
final _controller = StreamController<DispositivoAudio>.broadcast();
|
||||
DispositivoAudio? _dispositivoActual;
|
||||
|
||||
@@ -481,6 +493,11 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resubscribir() async {
|
||||
resubscribirLlamadas++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoBluetooth() async {
|
||||
solicitarPermisoBluetoothCalls++;
|
||||
@@ -493,6 +510,14 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
||||
_controller.add(dispositivo);
|
||||
}
|
||||
|
||||
/// Helper for tests: change the cached device WITHOUT emitting a stream
|
||||
/// event — simulates the stale-subscription bug (activity recreation),
|
||||
/// where the native device changed but the event never reached Dart and
|
||||
/// only a fresh query ([obtenerDispositivoActual]) sees the new value.
|
||||
void establecerDispositivoSinEvento(DispositivoAudio dispositivo) {
|
||||
_dispositivoActual = dispositivo;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _controller.close();
|
||||
|
||||
@@ -2,12 +2,14 @@ import 'dart:math' show Random;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/dispositivo_audio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/modelos/pista_local.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
||||
|
||||
void main() {
|
||||
group('faviconUsable', () {
|
||||
@@ -3050,6 +3052,624 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// EQ gráfico personalizado en Android Auto (feature auto-custom-eq)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
group('ConstructorArbolAuto.itemEqPersonalizado', () {
|
||||
test('carpeta no reproducible con id eq_custom y título Personalizado', () {
|
||||
final item = ConstructorArbolAuto().itemEqPersonalizado();
|
||||
|
||||
expect(item.id, ConstructorArbolAuto.idEqPersonalizado);
|
||||
expect(item.id, 'eq_custom');
|
||||
expect(item.title, 'Personalizado');
|
||||
expect(item.playable, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('formatearGananciaEq', () {
|
||||
test('positivos con signo +, cero sin signo, negativos con -', () {
|
||||
expect(formatearGananciaEq(4), '+4 dB');
|
||||
expect(formatearGananciaEq(0), '0 dB');
|
||||
expect(formatearGananciaEq(-6), '-6 dB');
|
||||
expect(formatearGananciaEq(12), '+12 dB');
|
||||
expect(formatearGananciaEq(-12), '-12 dB');
|
||||
});
|
||||
|
||||
test('ganancias no enteras (presets de fábrica) muestran un decimal', () {
|
||||
expect(formatearGananciaEq(1.5), '+1.5 dB');
|
||||
expect(formatearGananciaEq(-1.5), '-1.5 dB');
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.bandasEq', () {
|
||||
test(
|
||||
'5 carpetas eq_banda:<i> con frecuencia y ganancia actual en el título',
|
||||
() {
|
||||
final preset = PresetEcualizador(
|
||||
nombre: 'Personalizado',
|
||||
bandas: [3.0, 0.0, -6.0, 1.5, 12.0],
|
||||
);
|
||||
|
||||
final items = ConstructorArbolAuto().bandasEq(preset);
|
||||
|
||||
expect(items, hasLength(5));
|
||||
expect(items.map((i) => i.id), [
|
||||
'eq_banda:0',
|
||||
'eq_banda:1',
|
||||
'eq_banda:2',
|
||||
'eq_banda:3',
|
||||
'eq_banda:4',
|
||||
]);
|
||||
expect(items[0].title, '60 Hz · +3 dB');
|
||||
expect(items[1].title, '250 Hz · 0 dB');
|
||||
expect(items[2].title, '1 kHz · -6 dB');
|
||||
expect(items[3].title, '4 kHz · +1.5 dB');
|
||||
expect(items[4].title, '16 kHz · +12 dB');
|
||||
expect(items.every((i) => i.playable == false), isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.gananciasBandaEq', () {
|
||||
test(
|
||||
'13 items reproducibles eq_gain:<i>:<g> de -12 a +12 en pasos de 2',
|
||||
() {
|
||||
final items = ConstructorArbolAuto().gananciasBandaEq(
|
||||
1,
|
||||
PresetEcualizador.flat,
|
||||
);
|
||||
|
||||
expect(items, hasLength(13));
|
||||
expect(items.first.id, 'eq_gain:1:-12');
|
||||
expect(items.last.id, 'eq_gain:1:12');
|
||||
expect(items.every((i) => i.playable == true), isTrue);
|
||||
expect(items.first.title, '-12 dB');
|
||||
expect(items.last.title, '+12 dB');
|
||||
},
|
||||
);
|
||||
|
||||
test('marca la ganancia actualmente seleccionada con "● "', () {
|
||||
final preset = PresetEcualizador(
|
||||
nombre: 'Personalizado',
|
||||
bandas: [0.0, 4.0, 0.0, 0.0, 0.0],
|
||||
);
|
||||
|
||||
final items = ConstructorArbolAuto().gananciasBandaEq(1, preset);
|
||||
|
||||
final marcados = items.where((i) => i.title.startsWith('● ')).toList();
|
||||
expect(marcados, hasLength(1));
|
||||
expect(marcados.single.id, 'eq_gain:1:4');
|
||||
expect(marcados.single.title, '● +4 dB');
|
||||
});
|
||||
|
||||
test(
|
||||
'ganancia actual fuera de la retícula (impar) no marca ningún item',
|
||||
() {
|
||||
// Rock: bandas [2.0, 1.0, -1.0, 2.0, 3.0] — banda 1 vale 1.0.
|
||||
final items = ConstructorArbolAuto().gananciasBandaEq(
|
||||
1,
|
||||
PresetEcualizador.rock,
|
||||
);
|
||||
|
||||
expect(items.where((i) => i.title.startsWith('● ')), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('índice fuera de rango devuelve lista vacía, nunca lanza', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
expect(constructor.gananciasBandaEq(-1, PresetEcualizador.flat), isEmpty);
|
||||
expect(constructor.gananciasBandaEq(5, PresetEcualizador.flat), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('esBandaEqMediaId / indiceBandaEqDesde', () {
|
||||
test('reconoce ids eq_banda: y extrae el índice', () {
|
||||
expect(esBandaEqMediaId('eq_banda:0'), isTrue);
|
||||
expect(esBandaEqMediaId('eq_banda:4'), isTrue);
|
||||
expect(esBandaEqMediaId('eq_custom'), isFalse);
|
||||
expect(esBandaEqMediaId('eq_gain:0:2'), isFalse);
|
||||
|
||||
expect(indiceBandaEqDesde('eq_banda:0'), 0);
|
||||
expect(indiceBandaEqDesde('eq_banda:4'), 4);
|
||||
});
|
||||
|
||||
test('ids malformados o fuera de rango devuelven null, nunca lanzan', () {
|
||||
expect(indiceBandaEqDesde('eq_banda:'), isNull);
|
||||
expect(indiceBandaEqDesde('eq_banda:x'), isNull);
|
||||
expect(indiceBandaEqDesde('eq_banda:5'), isNull);
|
||||
expect(indiceBandaEqDesde('eq_banda:-1'), isNull);
|
||||
expect(indiceBandaEqDesde('emisora:x'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('esGananciaEqMediaId / gananciaEqDesde', () {
|
||||
test('reconoce ids eq_gain: y extrae (índice, dB)', () {
|
||||
expect(esGananciaEqMediaId('eq_gain:0:2'), isTrue);
|
||||
expect(esGananciaEqMediaId('eq_banda:0'), isFalse);
|
||||
|
||||
expect(gananciaEqDesde('eq_gain:0:2'), (0, 2.0));
|
||||
expect(gananciaEqDesde('eq_gain:4:-12'), (4, -12.0));
|
||||
expect(gananciaEqDesde('eq_gain:2:0'), (2, 0.0));
|
||||
});
|
||||
|
||||
test('ids malformados o fuera de rango devuelven null, nunca lanzan', () {
|
||||
expect(gananciaEqDesde('eq_gain:'), isNull);
|
||||
expect(gananciaEqDesde('eq_gain:0'), isNull);
|
||||
expect(gananciaEqDesde('eq_gain:0:'), isNull);
|
||||
expect(gananciaEqDesde('eq_gain:x:2'), isNull);
|
||||
expect(gananciaEqDesde('eq_gain:0:x'), isNull);
|
||||
expect(gananciaEqDesde('eq_gain:5:2'), isNull);
|
||||
expect(gananciaEqDesde('eq_gain:0:14'), isNull);
|
||||
expect(gananciaEqDesde('eq_gain:0:-14'), isNull);
|
||||
expect(gananciaEqDesde('emisora:x'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('dispositivoDestinoEq', () {
|
||||
const btDevice = DispositivoAudio(
|
||||
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'BT Speaker',
|
||||
);
|
||||
const builtin = DispositivoAudio(
|
||||
id: 'builtin_speaker',
|
||||
tipo: TipoDispositivo.altavozInterno,
|
||||
nombre: 'Speaker',
|
||||
);
|
||||
const placeholder = DispositivoAudio(
|
||||
id: 'bt_a2dp:name:My Speaker',
|
||||
tipo: TipoDispositivo.bluetoothA2dp,
|
||||
nombre: 'My Speaker',
|
||||
);
|
||||
|
||||
test('dispositivo BT real con multi-device activo → su id', () {
|
||||
expect(
|
||||
dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: btDevice),
|
||||
btDevice.id,
|
||||
);
|
||||
});
|
||||
|
||||
test('multi-device apagado → null (persistencia global)', () {
|
||||
expect(
|
||||
dispositivoDestinoEq(multiDeviceEnabled: false, dispositivo: btDevice),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('altavoz interno → null (nunca entrada por dispositivo)', () {
|
||||
expect(
|
||||
dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: builtin),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('placeholder compuesto bt_a2dp:name: → null (ADR-6)', () {
|
||||
expect(
|
||||
dispositivoDestinoEq(
|
||||
multiDeviceEnabled: true,
|
||||
dispositivo: placeholder,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('dispositivo desconocido (consulta fallida) → null', () {
|
||||
expect(
|
||||
dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: null),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('presetPersonalizadoEfectivo', () {
|
||||
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
||||
|
||||
test('entrada por dispositivo gana cuando multi-device está activo', () {
|
||||
final config = ConfiguracionEcualizador(
|
||||
principal: PresetEcualizador.pop,
|
||||
porEmisora: const {},
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {deviceId: PresetEcualizador.jazz},
|
||||
);
|
||||
|
||||
expect(
|
||||
presetPersonalizadoEfectivo(config: config, deviceId: deviceId),
|
||||
PresetEcualizador.jazz,
|
||||
);
|
||||
});
|
||||
|
||||
test('sin entrada por dispositivo cae al principal', () {
|
||||
final config = ConfiguracionEcualizador(
|
||||
principal: PresetEcualizador.pop,
|
||||
porEmisora: const {},
|
||||
eqMultiDeviceEnabled: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
presetPersonalizadoEfectivo(config: config, deviceId: deviceId),
|
||||
PresetEcualizador.pop,
|
||||
);
|
||||
});
|
||||
|
||||
test('multi-device apagado ignora la entrada por dispositivo', () {
|
||||
final config = ConfiguracionEcualizador(
|
||||
principal: PresetEcualizador.pop,
|
||||
porEmisora: const {},
|
||||
eqMultiDeviceEnabled: false,
|
||||
presetsDispositivo: {deviceId: PresetEcualizador.jazz},
|
||||
);
|
||||
|
||||
expect(
|
||||
presetPersonalizadoEfectivo(config: config, deviceId: deviceId),
|
||||
PresetEcualizador.pop,
|
||||
);
|
||||
});
|
||||
|
||||
test('deviceId null (destino global) cae al principal', () {
|
||||
final config = ConfiguracionEcualizador(
|
||||
principal: PresetEcualizador.pop,
|
||||
porEmisora: const {},
|
||||
eqMultiDeviceEnabled: true,
|
||||
presetsDispositivo: {deviceId: PresetEcualizador.jazz},
|
||||
);
|
||||
|
||||
expect(
|
||||
presetPersonalizadoEfectivo(config: config, deviceId: null),
|
||||
PresetEcualizador.pop,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('debeAplicarSeleccionAhora', () {
|
||||
const deviceId = 'bt_a2dp:AA:BB';
|
||||
|
||||
test('true sin estación actual', () {
|
||||
expect(
|
||||
debeAplicarSeleccionAhora(
|
||||
uuidActual: null,
|
||||
clavesPorEmisora: const {'uuid-otra'},
|
||||
clavesMatriz: const {},
|
||||
deviceIdDestino: deviceId,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('false cuando la estación actual tiene override por-emisora', () {
|
||||
expect(
|
||||
debeAplicarSeleccionAhora(
|
||||
uuidActual: 'uuid-con-override',
|
||||
clavesPorEmisora: const {'uuid-con-override'},
|
||||
clavesMatriz: const {},
|
||||
deviceIdDestino: deviceId,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'false cuando existe entrada de matriz estación:dispositivo destino',
|
||||
() {
|
||||
expect(
|
||||
debeAplicarSeleccionAhora(
|
||||
uuidActual: 'uuid-1',
|
||||
clavesPorEmisora: const {},
|
||||
clavesMatriz: const {'uuid-1:$deviceId'},
|
||||
deviceIdDestino: deviceId,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('true cuando la matriz solo cubre otro dispositivo', () {
|
||||
expect(
|
||||
debeAplicarSeleccionAhora(
|
||||
uuidActual: 'uuid-1',
|
||||
clavesPorEmisora: const {},
|
||||
clavesMatriz: const {'uuid-1:otro-dispositivo'},
|
||||
deviceIdDestino: deviceId,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('aplicarPresetPorMediaId — persistencia dirigida por dispositivo', () {
|
||||
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
||||
|
||||
test(
|
||||
'con destino de dispositivo persiste a nivel dispositivo, nunca al '
|
||||
'principal, y aplica en vivo cuando no hay overrides',
|
||||
() async {
|
||||
final persistidosDispositivo = <(String, PresetEcualizador)>[];
|
||||
var persistirPrincipalLlamadas = 0;
|
||||
PresetEcualizador? aplicado;
|
||||
|
||||
await aplicarPresetPorMediaId(
|
||||
'eq_preset:${PresetEcualizador.rock.nombre}',
|
||||
presets: PresetEcualizador.presets,
|
||||
uuidActual: null,
|
||||
clavesPorEmisora: () async => <String>{},
|
||||
clavesMatriz: () async => <String>{},
|
||||
dispositivoDestino: () async => deviceId,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
persistidosDispositivo.add((id, preset));
|
||||
},
|
||||
persistirPrincipal: (preset) async {
|
||||
persistirPrincipalLlamadas++;
|
||||
},
|
||||
aplicar: (preset) async {
|
||||
aplicado = preset;
|
||||
},
|
||||
);
|
||||
|
||||
expect(persistidosDispositivo, [(deviceId, PresetEcualizador.rock)]);
|
||||
expect(persistirPrincipalLlamadas, 0);
|
||||
expect(aplicado, PresetEcualizador.rock);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con destino null (builtin/apagado/error) cae a la persistencia '
|
||||
'principal como hasta ahora',
|
||||
() async {
|
||||
PresetEcualizador? persistidoPrincipal;
|
||||
var persistirDispositivoLlamadas = 0;
|
||||
|
||||
await aplicarPresetPorMediaId(
|
||||
'eq_preset:${PresetEcualizador.jazz.nombre}',
|
||||
presets: PresetEcualizador.presets,
|
||||
uuidActual: null,
|
||||
clavesPorEmisora: () async => <String>{},
|
||||
clavesMatriz: () async => <String>{},
|
||||
dispositivoDestino: () async => null,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
persistirDispositivoLlamadas++;
|
||||
},
|
||||
persistirPrincipal: (preset) async {
|
||||
persistidoPrincipal = preset;
|
||||
},
|
||||
aplicar: (preset) async {},
|
||||
);
|
||||
|
||||
expect(persistidoPrincipal, PresetEcualizador.jazz);
|
||||
expect(persistirDispositivoLlamadas, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'persiste a dispositivo pero NO aplica en vivo cuando la matriz '
|
||||
'estación:dispositivo hace sombra',
|
||||
() async {
|
||||
var persistirDispositivoLlamadas = 0;
|
||||
var aplicarLlamadas = 0;
|
||||
|
||||
await aplicarPresetPorMediaId(
|
||||
'eq_preset:${PresetEcualizador.rock.nombre}',
|
||||
presets: PresetEcualizador.presets,
|
||||
uuidActual: 'uuid-1',
|
||||
clavesPorEmisora: () async => <String>{},
|
||||
clavesMatriz: () async => {'uuid-1:$deviceId'},
|
||||
dispositivoDestino: () async => deviceId,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
persistirDispositivoLlamadas++;
|
||||
},
|
||||
persistirPrincipal: (preset) async {},
|
||||
aplicar: (preset) async {
|
||||
aplicarLlamadas++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(persistirDispositivoLlamadas, 1);
|
||||
expect(aplicarLlamadas, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('aplicarGananciaPorMediaId', () {
|
||||
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
||||
|
||||
ConfiguracionEcualizador configCon({
|
||||
PresetEcualizador? principal,
|
||||
Map<String, PresetEcualizador>? presetsDispositivo,
|
||||
bool multiDevice = true,
|
||||
}) => ConfiguracionEcualizador(
|
||||
principal: principal ?? PresetEcualizador.flat,
|
||||
porEmisora: const {},
|
||||
eqMultiDeviceEnabled: multiDevice,
|
||||
presetsDispositivo: presetsDispositivo ?? const {},
|
||||
);
|
||||
|
||||
test(
|
||||
'con destino de dispositivo: modifica solo la banda, persiste como '
|
||||
'Personalizado a nivel dispositivo y aplica la banda en vivo',
|
||||
() async {
|
||||
final base = PresetEcualizador.jazz; // [3, -1, -1.5, 2, 4]
|
||||
final persistidosDispositivo = <(String, PresetEcualizador)>[];
|
||||
var persistirPrincipalLlamadas = 0;
|
||||
final bandasAplicadas = <(int, double)>[];
|
||||
|
||||
await aplicarGananciaPorMediaId(
|
||||
'eq_gain:1:4',
|
||||
cargarConfig: () async =>
|
||||
configCon(presetsDispositivo: {deviceId: base}),
|
||||
dispositivoDestino: () async => deviceId,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
persistidosDispositivo.add((id, preset));
|
||||
},
|
||||
persistirPrincipal: (preset) async {
|
||||
persistirPrincipalLlamadas++;
|
||||
},
|
||||
aplicarBanda: (indice, db) async {
|
||||
bandasAplicadas.add((indice, db));
|
||||
},
|
||||
);
|
||||
|
||||
expect(persistirPrincipalLlamadas, 0);
|
||||
expect(persistidosDispositivo, hasLength(1));
|
||||
final (idPersistido, presetPersistido) = persistidosDispositivo.single;
|
||||
expect(idPersistido, deviceId);
|
||||
expect(presetPersistido.nombre, 'Personalizado');
|
||||
expect(presetPersistido.bandas, [3.0, 4.0, -1.5, 2.0, 4.0]);
|
||||
expect(bandasAplicadas, [(1, 4.0)]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con destino null persiste al principal partiendo de sus bandas',
|
||||
() async {
|
||||
PresetEcualizador? persistidoPrincipal;
|
||||
var persistirDispositivoLlamadas = 0;
|
||||
|
||||
await aplicarGananciaPorMediaId(
|
||||
'eq_gain:0:-2',
|
||||
cargarConfig: () async =>
|
||||
configCon(principal: PresetEcualizador.pop, multiDevice: false),
|
||||
dispositivoDestino: () async => null,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
persistirDispositivoLlamadas++;
|
||||
},
|
||||
persistirPrincipal: (preset) async {
|
||||
persistidoPrincipal = preset;
|
||||
},
|
||||
aplicarBanda: (indice, db) async {},
|
||||
);
|
||||
|
||||
expect(persistirDispositivoLlamadas, 0);
|
||||
expect(persistidoPrincipal, isNotNull);
|
||||
expect(persistidoPrincipal!.nombre, 'Personalizado');
|
||||
// Pop: [1.0, 1.5, 0.5, 1.0, 1.5] con banda 0 → -2.
|
||||
expect(persistidoPrincipal!.bandas, [-2.0, 1.5, 0.5, 1.0, 1.5]);
|
||||
},
|
||||
);
|
||||
|
||||
test('id malformado: no persiste, no aplica, no lanza', () async {
|
||||
var llamadas = 0;
|
||||
|
||||
await aplicarGananciaPorMediaId(
|
||||
'eq_gain:9:99',
|
||||
cargarConfig: () async => configCon(),
|
||||
dispositivoDestino: () async => deviceId,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
llamadas++;
|
||||
},
|
||||
persistirPrincipal: (preset) async {
|
||||
llamadas++;
|
||||
},
|
||||
aplicarBanda: (indice, db) async {
|
||||
llamadas++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(llamadas, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'cargarConfig que lanza degrada a no-op sin propagar la excepción',
|
||||
() async {
|
||||
var llamadas = 0;
|
||||
|
||||
await aplicarGananciaPorMediaId(
|
||||
'eq_gain:0:2',
|
||||
cargarConfig: () async => throw Exception('prefs rotas'),
|
||||
dispositivoDestino: () async => deviceId,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
llamadas++;
|
||||
},
|
||||
persistirPrincipal: (preset) async {
|
||||
llamadas++;
|
||||
},
|
||||
aplicarBanda: (indice, db) async {
|
||||
llamadas++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(llamadas, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con override por-emisora de la estación actual persiste pero NO '
|
||||
'aplica la banda en vivo',
|
||||
() async {
|
||||
final persistidosDispositivo = <(String, PresetEcualizador)>[];
|
||||
final bandasAplicadas = <(int, double)>[];
|
||||
|
||||
await aplicarGananciaPorMediaId(
|
||||
'eq_gain:1:4',
|
||||
cargarConfig: () async => configCon(),
|
||||
dispositivoDestino: () async => deviceId,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
persistidosDispositivo.add((id, preset));
|
||||
},
|
||||
persistirPrincipal: (preset) async {},
|
||||
aplicarBanda: (indice, db) async {
|
||||
bandasAplicadas.add((indice, db));
|
||||
},
|
||||
uuidActual: 'uuid-1',
|
||||
clavesPorEmisora: () async => {'uuid-1'},
|
||||
clavesMatriz: () async => <String>{},
|
||||
);
|
||||
|
||||
expect(persistidosDispositivo, hasLength(1));
|
||||
expect(bandasAplicadas, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con entrada de matriz estación:dispositivo destino persiste pero NO '
|
||||
'aplica la banda en vivo',
|
||||
() async {
|
||||
final persistidosDispositivo = <(String, PresetEcualizador)>[];
|
||||
final bandasAplicadas = <(int, double)>[];
|
||||
|
||||
await aplicarGananciaPorMediaId(
|
||||
'eq_gain:1:4',
|
||||
cargarConfig: () async => configCon(),
|
||||
dispositivoDestino: () async => deviceId,
|
||||
persistirDispositivo: (id, preset) async {
|
||||
persistidosDispositivo.add((id, preset));
|
||||
},
|
||||
persistirPrincipal: (preset) async {},
|
||||
aplicarBanda: (indice, db) async {
|
||||
bandasAplicadas.add((indice, db));
|
||||
},
|
||||
uuidActual: 'uuid-1',
|
||||
clavesPorEmisora: () async => <String>{},
|
||||
clavesMatriz: () async => {'uuid-1:$deviceId'},
|
||||
);
|
||||
|
||||
expect(persistidosDispositivo, hasLength(1));
|
||||
expect(bandasAplicadas, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con estación actual sin overrides sí aplica la banda en vivo',
|
||||
() async {
|
||||
final bandasAplicadas = <(int, double)>[];
|
||||
|
||||
await aplicarGananciaPorMediaId(
|
||||
'eq_gain:1:4',
|
||||
cargarConfig: () async => configCon(),
|
||||
dispositivoDestino: () async => deviceId,
|
||||
persistirDispositivo: (id, preset) async {},
|
||||
persistirPrincipal: (preset) async {},
|
||||
aplicarBanda: (indice, db) async {
|
||||
bandasAplicadas.add((indice, db));
|
||||
},
|
||||
uuidActual: 'uuid-1',
|
||||
clavesPorEmisora: () async => <String>{},
|
||||
clavesMatriz: () async => <String>{},
|
||||
);
|
||||
|
||||
expect(bandasAplicadas, [(1, 4.0)]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fake `metadatosDe` that always resolves to an empty map — used by every
|
||||
|
||||
@@ -146,5 +146,61 @@ void main() {
|
||||
expect(granted, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Fix: robust active-device detection (stale green dot) ────────────────
|
||||
|
||||
test(
|
||||
'resubscribir sends cancel+listen to the event channel and forwards '
|
||||
'the device the fresh onListen emits',
|
||||
() async {
|
||||
var listens = 0;
|
||||
var cancels = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockStreamHandler(
|
||||
const EventChannel(methodChannelName),
|
||||
MockStreamHandler.inline(
|
||||
onListen: (arguments, events) {
|
||||
listens++;
|
||||
// Mirrors the native onListen immediate resync: every
|
||||
// (re)subscription receives the current active device.
|
||||
events.success({
|
||||
'id': 'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
||||
'type': 8,
|
||||
'name': 'My BT Device',
|
||||
});
|
||||
},
|
||||
onCancel: (arguments) => cancels++,
|
||||
),
|
||||
);
|
||||
addTearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockStreamHandler(
|
||||
const EventChannel(methodChannelName),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
final servicioLocal = ServicioDispositivoAudioReal();
|
||||
addTearDown(servicioLocal.dispose);
|
||||
|
||||
// The constructor's initial subscription delivers the first resync.
|
||||
final primero = await servicioLocal.onDispositivoCambiado.first;
|
||||
expect(primero.id, 'bt_a2dp:AA:BB:CC:DD:EE:FF');
|
||||
expect(listens, 1);
|
||||
expect(cancels, 0);
|
||||
|
||||
final segundoFuturo = servicioLocal.onDispositivoCambiado.first;
|
||||
await servicioLocal.resubscribir();
|
||||
final segundo = await segundoFuturo;
|
||||
|
||||
expect(cancels, 1);
|
||||
expect(listens, 2);
|
||||
expect(segundo.tipo, TipoDispositivo.bluetoothA2dp);
|
||||
expect(
|
||||
servicioLocal.dispositivoActual?.id,
|
||||
'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ class NullServicioDispositivoAudio extends ServicioDispositivoAudio {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resubscribir() async {}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoBluetooth() async => true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user