Files
pluriwave/test/servicios/servicio_ecualizador_test.dart
FreeTLab 4042cf5ffd
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
fix(eq): stop the phone's FM sink from posing as the active output
Regression from the previous commit. Ranking every AudioDeviceInfo type this
build does not name individually ABOVE the built-in speaker was meant to let
a car stereo on LE Audio or an automotive bus win. It also promoted the
internal sinks a phone exposes permanently: on the Xiaomi test device
AudioManager reports TYPE_FM (14) as an output, so getActiveAudioDevice
picked it over the real speaker with nothing connected at all. Confirmed on
device:

  audio_devices.onListen -> {id=other:14:4, type=14, name=2412DPC0AG}

It then reached Dart under an `other:14:4` id whose type is neither the base
speaker nor a known one, slipped past the collision guard and had a preset
row persisted for it -- reinstating the exact symptom this series set out to
kill: a permanent green active-output dot on a device that was not connected.

Replace the deny-by-omission ranking with an explicit allow list of outputs a
user actually connects. The built-in speaker sits below all of them and above
everything else, so any sink that physically exists but is never where media
plays (TYPE_FM, TYPE_BUILTIN_SPEAKER_SAFE, telephony, remote submix) can no
longer be selected. A one-time purge clears the `other:` rows the bad build
persisted; genuine ones re-register on their next connection.

Fix the USB type constant while here: TYPE_USB_HEADSET is 22, not 14, and 14
is TYPE_FM. The Kotlin USB branch hardcoded 14 and the Dart type table
mirrored the same mistake, so the two cancelled out for real USB headsets
while making a phone's own FM sink decode as USB audio. Both now use 22.

Verified with javap against android.jar (android-36) rather than trusting the
comment that introduced the error.
2026-07-25 20:43:47 +02:00

700 lines
24 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
import 'package:shared_preferences/shared_preferences.dart';
Map<String, dynamic> _presetJson(String nombre) => {
'nombre': nombre,
'bandas': [1.0, 2.0, 3.0, 4.0, 5.0],
};
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('ServicioEcualizador — multi-device persistence (Phase 4)', () {
test('4.1a toggle defaults to false when no SP data', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.eqMultiDeviceEnabled, isFalse);
});
test(
'4.1b guardarToggleMultiDispositivo persists and cargar restores it',
() async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarToggleMultiDispositivo(true);
final config = await servicio.cargar();
expect(config.eqMultiDeviceEnabled, isTrue);
},
);
test('4.1c guardarPresetDispositivo stores a device preset', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
await servicio.guardarPresetDispositivo(deviceId, PresetEcualizador.jazz);
final config = await servicio.cargar();
expect(
config.presetsDispositivo[deviceId],
equals(PresetEcualizador.jazz),
);
});
test(
'4.1d obtenerPresetDispositivo returns null for unknown device',
() async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.presetsDispositivo['unknown_device'], isNull);
},
);
test('4.1e guardarPresetMatriz stores a matrix entry', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
const clave = 'station-uuid-123:bt_a2dp:AA:BB:CC:DD:EE:FF';
await servicio.guardarPresetMatriz(clave, PresetEcualizador.rock);
final config = await servicio.cargar();
expect(config.presetsMatriz[clave], equals(PresetEcualizador.rock));
});
test('4.1f multiple device presets persist independently', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
'bt_a2dp:11:22:33:44:55:66',
PresetEcualizador.rock,
);
await servicio.guardarPresetDispositivo(
'wired_headset',
PresetEcualizador.jazz,
);
final config = await servicio.cargar();
expect(
config.presetsDispositivo['bt_a2dp:11:22:33:44:55:66'],
equals(PresetEcualizador.rock),
);
expect(
config.presetsDispositivo['wired_headset'],
equals(PresetEcualizador.jazz),
);
});
test('4.1g multiple matrix entries persist independently', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetMatriz(
'station-A:bt_a2dp:AA:BB:CC:DD:EE:FF',
PresetEcualizador.jazz,
);
await servicio.guardarPresetMatriz(
'station-B:wired_headset',
PresetEcualizador.bassBoost,
);
final config = await servicio.cargar();
expect(
config.presetsMatriz['station-A:bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(PresetEcualizador.jazz),
);
expect(
config.presetsMatriz['station-B:wired_headset'],
equals(PresetEcualizador.bassBoost),
);
});
test(
'4.1h existing principal/emisora fields unaffected by new fields',
() async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPrincipal(PresetEcualizador.pop);
await servicio.guardarPorEmisora('station-X', PresetEcualizador.voz);
await servicio.guardarToggleMultiDispositivo(true);
// Any device id EXCEPT builtin_speaker: the phone speaker is the
// fallback every hierarchy level falls through to, so it never owns a
// device preset and cargar() purges it (see the collision-purge group).
await servicio.guardarPresetDispositivo(
'wired_headset',
PresetEcualizador.jazz,
);
final config = await servicio.cargar();
expect(config.principal, equals(PresetEcualizador.pop));
expect(config.porEmisora['station-X'], equals(PresetEcualizador.voz));
expect(config.eqMultiDeviceEnabled, isTrue);
expect(
config.presetsDispositivo['wired_headset'],
equals(PresetEcualizador.jazz),
);
},
);
test('4.1i guardarConfiguracion with new fields round-trips', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = ConfiguracionEcualizador(
principal: PresetEcualizador.rock,
porEmisora: {'station-1': PresetEcualizador.jazz},
activo: true,
eqMultiDeviceEnabled: true,
presetsDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.voz,
},
presetsMatriz: {
'station-1:bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.bassBoost,
},
);
await servicio.guardarConfiguracion(config);
final restored = await servicio.cargar();
expect(restored.principal, equals(PresetEcualizador.rock));
expect(restored.porEmisora['station-1'], equals(PresetEcualizador.jazz));
expect(restored.eqMultiDeviceEnabled, isTrue);
expect(
restored.presetsDispositivo['bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(PresetEcualizador.voz),
);
expect(
restored.presetsMatriz['station-1:bt_a2dp:AA:BB:CC:DD:EE:FF'],
equals(PresetEcualizador.bassBoost),
);
});
});
// ---------------------------------------------------------------------------
// Phase 1 (eq-device-autoswitch-ux): nombresDispositivos persistence
// ---------------------------------------------------------------------------
group('ServicioEcualizador — nombresDispositivos (eq-device-autoswitch-ux)', () {
// 1.1 RED — cargarNombresDispositivos returns empty map when SP key absent
test('1.1 cargar returns empty nombresDispositivos when SP key absent', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.nombresDispositivos, isEmpty);
});
// 1.2 RED — guardarNombresDispositivos writes to eq_nombres_dispositivos_v1 SP key
test('1.2 guardarNombresDispositivos persists names and cargar restores them', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarNombresDispositivos({'bt_a2dp:AA:BB': 'Living Room BT'});
final config = await servicio.cargar();
expect(config.nombresDispositivos['bt_a2dp:AA:BB'], equals('Living Room BT'));
});
// 1.3 RED — round-trip: save → load returns same map
test('1.3 round-trip save → cargar preserves all entries', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
// builtin_speaker is deliberately absent: cargar() purges it, since the
// phone speaker is the fallback output and never a nameable device row
// (see the collision-purge group).
const nombres = {
'bt_a2dp:AA:BB': 'Living Room BT',
'wired_headset': 'Office Headset',
'usb_headset:1': 'Desk DAC',
};
await servicio.guardarNombresDispositivos(nombres);
final config = await servicio.cargar();
expect(config.nombresDispositivos, equals(nombres));
});
// guardarConfiguracion also persists nombresDispositivos
test('1.7 guardarConfiguracion persists nombresDispositivos round-trip', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = ConfiguracionEcualizador(
principal: PresetEcualizador.flat,
porEmisora: {},
eqMultiDeviceEnabled: true,
presetsDispositivo: {},
presetsMatriz: {},
nombresDispositivos: {'bt_a2dp:AA:BB': 'My Speaker'},
);
await servicio.guardarConfiguracion(config);
final restored = await servicio.cargar();
expect(restored.nombresDispositivos['bt_a2dp:AA:BB'], equals('My Speaker'));
});
});
// ---------------------------------------------------------------------------
// bt-device-identity Phase 5: one-time guarded migration purge
// ---------------------------------------------------------------------------
group('ServicioEcualizador — placeholder migration purge (bt-device-identity Phase 5)', () {
const placeholderKey = 'bt_a2dp:02:00:00:00:00:00';
const stableKey = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
test(
'5.1 migration removes only exact placeholder entries from presetsPorDispositivo',
() async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
placeholderKey,
PresetEcualizador.jazz,
);
await servicio.guardarPresetDispositivo(
stableKey,
PresetEcualizador.rock,
);
await servicio.migrarClavesPlaceholder();
final config = await servicio.cargar();
expect(
config.presetsDispositivo.containsKey(placeholderKey),
isFalse,
);
expect(
config.presetsDispositivo[stableKey],
equals(PresetEcualizador.rock),
);
},
);
test('5.2 matrix keys purge only the placeholder segment', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetMatriz(
'station1:$placeholderKey',
PresetEcualizador.jazz,
);
await servicio.guardarPresetMatriz(
'station1:$stableKey',
PresetEcualizador.rock,
);
await servicio.migrarClavesPlaceholder();
final config = await servicio.cargar();
expect(
config.presetsMatriz.containsKey('station1:$placeholderKey'),
isFalse,
);
expect(
config.presetsMatriz['station1:$stableKey'],
equals(PresetEcualizador.rock),
);
});
test('5.3 near-miss keys are preserved (differs by one digit)', () async {
const nearMissKey = 'bt_a2dp:02:00:00:00:00:01';
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
nearMissKey,
PresetEcualizador.jazz,
);
await servicio.migrarClavesPlaceholder();
final config = await servicio.cargar();
expect(
config.presetsDispositivo[nearMissKey],
equals(PresetEcualizador.jazz),
);
});
test('5.4 migration runs once (second run is a true no-op)', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
placeholderKey,
PresetEcualizador.jazz,
);
await servicio.guardarPresetDispositivo(
stableKey,
PresetEcualizador.rock,
);
await servicio.migrarClavesPlaceholder();
// Re-seed the placeholder key directly, bypassing the flag, to prove
// the SECOND migration call is a true no-op (flag short-circuits)
// rather than merely finding nothing left to purge.
await servicio.guardarPresetDispositivo(
placeholderKey,
PresetEcualizador.bassBoost,
);
await servicio.migrarClavesPlaceholder();
final config = await servicio.cargar();
expect(
config.presetsDispositivo[placeholderKey],
equals(PresetEcualizador.bassBoost),
);
expect(
config.presetsDispositivo[stableKey],
equals(PresetEcualizador.rock),
);
});
test(
'5.5 no placeholder entries when BLUETOOTH_CONNECT was never '
'requested is a no-op',
() async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
stableKey,
PresetEcualizador.rock,
);
await servicio.migrarClavesPlaceholder();
final config = await servicio.cargar();
expect(
config.presetsDispositivo[stableKey],
equals(PresetEcualizador.rock),
);
expect(config.presetsDispositivo.length, equals(1));
},
);
test('5.6 nombresDispositivos also purges the exact placeholder key', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarNombresDispositivos({
placeholderKey: 'Ghost Device',
stableKey: 'Living Room BT',
});
await servicio.migrarClavesPlaceholder();
final config = await servicio.cargar();
expect(config.nombresDispositivos.containsKey(placeholderKey), isFalse);
expect(
config.nombresDispositivos[stableKey],
equals('Living Room BT'),
);
});
test('5.9 cargar() runs the migration automatically on first load', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
placeholderKey,
PresetEcualizador.jazz,
);
await servicio.guardarPresetDispositivo(
stableKey,
PresetEcualizador.rock,
);
// No explicit migrarClavesPlaceholder() call: cargar() must run it.
final config = await servicio.cargar();
expect(config.presetsDispositivo.containsKey(placeholderKey), isFalse);
expect(
config.presetsDispositivo[stableKey],
equals(PresetEcualizador.rock),
);
});
});
// ---------------------------------------------------------------------------
// persistence-resilience Phase C: per-entry tolerant reads for the 4 EQ
// map/single-value readers + degraded-read logging. NO flag/quarantine
// here (D6, intentional asymmetry vs. Alarms/Stations) — EQ presets are
// explicit-only writes and trivially re-creatable.
// ---------------------------------------------------------------------------
group(
'ServicioEcualizador — lectura tolerante y diagnostico (persistence-resilience)',
() {
late DebugPrintCallback debugPrintOriginal;
late List<String> logs;
setUp(() {
debugPrintOriginal = debugPrint;
logs = [];
debugPrint = (String? message, {int? wrapWidth}) {
if (message != null) logs.add(message);
};
});
tearDown(() {
debugPrint = debugPrintOriginal;
});
test(
'C2 presetsMatriz: sobrevive parcial con 2 entradas validas + 1 valor '
'corrupto, claves identicas al original',
() async {
final raw = jsonEncode({
'station-1:device-a': _presetJson('Rock'),
'station-2:device-b': _presetJson('Pop'),
'station-3:device-c': {'nombre': 'Malo', 'bandas': 'no-es-lista'},
});
SharedPreferences.setMockInitialValues({
'eq_presets_matriz_v1': raw,
});
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.presetsMatriz.keys.toSet(), {
'station-1:device-a',
'station-2:device-b',
});
expect(config.presetsMatriz['station-1:device-a']!.nombre, 'Rock');
expect(config.presetsMatriz['station-2:device-b']!.nombre, 'Pop');
},
);
test(
'C3 presetsMatriz: clave sobreviviente con deviceId estilo MAC '
'(varios dos puntos) queda intacta pese a un vecino corrupto',
() async {
const claveMac = 'station-X:bt_a2dp:AA:BB:CC:DD:EE:FF';
final raw = jsonEncode({
claveMac: _presetJson('Jazz'),
'station-Y:wired_headset': _presetJson('Rock'),
'station-Z:corrupt': {'nombre': 'Malo', 'bandas': 'no-es-lista'},
});
SharedPreferences.setMockInitialValues({
'eq_presets_matriz_v1': raw,
});
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.presetsMatriz.keys.toSet(), {
claveMac,
'station-Y:wired_headset',
});
expect(config.presetsMatriz[claveMac]!.nombre, 'Jazz');
},
);
test(
'C4 presetsDispositivo totalmente corrupto: cargar() queda vacio, '
'guardarPresetDispositivo sigue escribiendo (sin bandera en EQ)',
() async {
SharedPreferences.setMockInitialValues({
'eq_preset_por_dispositivo_v1': '{bad',
});
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.presetsDispositivo, isEmpty);
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
await servicio.guardarPresetDispositivo(
deviceId,
PresetEcualizador.rock,
);
final tras = await servicio.cargar();
expect(
tras.presetsDispositivo[deviceId],
equals(PresetEcualizador.rock),
);
},
);
test(
'C5 preset principal totalmente corrupto: cargar() retorna flat y '
'loguea la lectura degradada',
() async {
SharedPreferences.setMockInitialValues({
'eq_preset_principal_v1': '{bad',
});
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(config.principal, equals(PresetEcualizador.flat));
expect(
logs.any((l) => l.startsWith('[PluriWave][persistencia]')),
isTrue,
reason:
'una lectura degradada del preset principal debe quedar '
'registrada (antes silenciosa)',
);
},
);
test(
'C6 nombresDispositivos: sobrevive parcial con 2 nombres validos + 1 '
'valor corrupto',
() async {
final raw = jsonEncode({
'bt_a2dp:AA:BB': 'Living Room BT',
'wired_headset': 'Office Headset',
'builtin_speaker': 12345, // not a String -> must be skipped
});
SharedPreferences.setMockInitialValues({
'eq_nombres_dispositivos_v1': raw,
});
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
final config = await servicio.cargar();
expect(
config.nombresDispositivos,
equals({
'bt_a2dp:AA:BB': 'Living Room BT',
'wired_headset': 'Office Headset',
}),
);
},
);
},
);
// ---------------------------------------------------------------------------
// builtin_speaker id-collision purge
//
// MainActivity.deviceToMap used to hand the phone-speaker id to ANY output
// type it did not know by name (LE Audio, car bus, dock). Dart then created a
// device entry keyed 'builtin_speaker', which permanently marked whatever the
// user renamed it to as the active device every time audio played through the
// phone's own speaker. The native id is fixed; this purge clears the entries
// that collision already persisted.
// ---------------------------------------------------------------------------
group('ServicioEcualizador — builtin_speaker collision purge', () {
const baseKey = 'builtin_speaker';
const stableKey = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
test('removes the builtin_speaker preset and keeps its siblings', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(baseKey, PresetEcualizador.jazz);
await servicio.guardarPresetDispositivo(
stableKey,
PresetEcualizador.rock,
);
final config = await servicio.cargar();
expect(config.presetsDispositivo.containsKey(baseKey), isFalse);
expect(
config.presetsDispositivo[stableKey],
equals(PresetEcualizador.rock),
);
});
test('removes matrix entries whose device segment is the base id', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetMatriz(
'station1:$baseKey',
PresetEcualizador.jazz,
);
await servicio.guardarPresetMatriz(
'station1:$stableKey',
PresetEcualizador.rock,
);
final config = await servicio.cargar();
expect(config.presetsMatriz.containsKey('station1:$baseKey'), isFalse);
expect(
config.presetsMatriz['station1:$stableKey'],
equals(PresetEcualizador.rock),
);
});
test('removes the custom name saved against the base id', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarNombresDispositivos({
baseKey: 'Omoda',
stableKey: 'Travel headphones',
});
final config = await servicio.cargar();
expect(config.nombresDispositivos.containsKey(baseKey), isFalse);
expect(
config.nombresDispositivos[stableKey],
equals('Travel headphones'),
);
});
// A build that ranked every unlisted AudioDeviceInfo type above the phone
// speaker selected permanent internal sinks (TYPE_FM on Xiaomi) as the
// active output and persisted `other:` rows for them.
test('removes other: rows persisted for internal sinks', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
'other:14:4',
PresetEcualizador.jazz,
);
await servicio.guardarNombresDispositivos({'other:14:4': 'Omoda'});
await servicio.guardarPresetMatriz(
'station1:other:14:4',
PresetEcualizador.pop,
);
await servicio.guardarPresetDispositivo(
stableKey,
PresetEcualizador.rock,
);
final config = await servicio.cargar();
expect(config.presetsDispositivo.containsKey('other:14:4'), isFalse);
expect(config.nombresDispositivos.containsKey('other:14:4'), isFalse);
expect(config.presetsMatriz.containsKey('station1:other:14:4'), isFalse);
expect(
config.presetsDispositivo[stableKey],
equals(PresetEcualizador.rock),
);
});
test('is guarded: a re-seeded base entry survives a second load', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(baseKey, PresetEcualizador.jazz);
await servicio.cargar();
// Re-seed after the flag is set, to prove the guard short-circuits rather
// than the purge merely finding nothing left to remove.
await servicio.guardarPresetDispositivo(baseKey, PresetEcualizador.rock);
final config = await servicio.cargar();
expect(
config.presetsDispositivo[baseKey],
equals(PresetEcualizador.rock),
);
});
});
}