feat(eq): android auto custom equalizer and robust device detection
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s

- 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:
Javier Bautista Fernández
2026-07-22 10:26:02 +02:00
parent 3473034b58
commit 163ff69f7a
29 changed files with 1883 additions and 42 deletions
+151
View File
@@ -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;
}
}
+25
View File
@@ -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();
+620
View File
@@ -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;