fix(devices): cache platform device names, dedupe placeholder ids, purge collided EQ entries
Dart half of bt-device-identity. EstadoEcualizador now caches each device's platform-reported name in memory so the settings screen shows the device's own Bluetooth name instead of its raw id when no custom rename exists, and skips auto-creating preset entries for the composite-placeholder sentinel. Enabling multi-device EQ triggers the Bluetooth permission request through the new channel contract. A flag-guarded one-time migration purges only entries keyed by the exact literal placeholder id from the three per-device preference maps, since those collided entries cannot be attributed to a device. Work unit 2/2 of bt-device-identity (Dart state + migration).
This commit is contained in:
@@ -26,6 +26,12 @@ import '../servicios/servicio_ecualizador.dart';
|
|||||||
/// When the toggle is false, resolution falls back to the original 2-level
|
/// When the toggle is false, resolution falls back to the original 2-level
|
||||||
/// hierarchy (station → global) — zero behavioral change vs. prior releases.
|
/// hierarchy (station → global) — zero behavioral change vs. prior releases.
|
||||||
class EstadoEcualizador extends ChangeNotifier {
|
class EstadoEcualizador extends ChangeNotifier {
|
||||||
|
/// Composite-placeholder id prefix (bt-device-identity ADR-6): marks a
|
||||||
|
/// device whose real Bluetooth MAC is not yet known (BLUETOOTH_CONNECT
|
||||||
|
/// denied or unresolved). Devices with this prefix still update the
|
||||||
|
/// platform-name cache but never auto-create a `presetsDispositivo` entry.
|
||||||
|
static const _prefijoPlaceholderCompuesto = 'bt_a2dp:name:';
|
||||||
|
|
||||||
EstadoEcualizador({
|
EstadoEcualizador({
|
||||||
required this.audio,
|
required this.audio,
|
||||||
ServicioEcualizador? servicio,
|
ServicioEcualizador? servicio,
|
||||||
@@ -54,6 +60,13 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
/// Custom display names for devices: deviceId → custom name.
|
/// Custom display names for devices: deviceId → custom name.
|
||||||
final Map<String, String> _nombresDispositivos = {};
|
final Map<String, String> _nombresDispositivos = {};
|
||||||
|
|
||||||
|
/// Last-seen platform (Bluetooth/productName) name per deviceId.
|
||||||
|
///
|
||||||
|
/// In-memory only (bt-device-identity ADR-4) — NOT persisted. Devices
|
||||||
|
/// re-report their name on every enumeration, so this cache self-heals
|
||||||
|
/// every session without needing a SharedPreferences key or migration.
|
||||||
|
final Map<String, String> _nombresPlataforma = {};
|
||||||
|
|
||||||
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
|
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
|
||||||
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
||||||
bool _activo = true;
|
bool _activo = true;
|
||||||
@@ -210,15 +223,27 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
Future<void> _onDispositivoCambiado(DispositivoAudio dispositivo) async {
|
Future<void> _onDispositivoCambiado(DispositivoAudio dispositivo) async {
|
||||||
if (!_eqMultiDeviceEnabled) return;
|
if (!_eqMultiDeviceEnabled) return;
|
||||||
|
|
||||||
|
// Cache updates on every event regardless of whether a preset entry
|
||||||
|
// gets created below (bt-device-identity ADR-4).
|
||||||
|
_nombresPlataforma[dispositivo.id] = dispositivo.nombre;
|
||||||
_dispositivoActualId = dispositivo.id;
|
_dispositivoActualId = dispositivo.id;
|
||||||
|
|
||||||
// First-seen device: copy the current resolved preset as its starting
|
// First-seen device: copy the current resolved preset as its starting
|
||||||
// point. builtin_speaker is excluded: it must always fall through to
|
// point. builtin_speaker is excluded: it must always fall through to
|
||||||
// the hierarchy (L4 global) instead of being pinned by a forced L3
|
// the hierarchy (L4 global) instead of being pinned by a forced L3
|
||||||
// device-level copy, otherwise a later global-preset change would be
|
// device-level copy, otherwise a later global-preset change would be
|
||||||
// masked by this stale entry for the base device.
|
// masked by this stale entry for the base device. Composite-placeholder
|
||||||
|
// ids are also excluded (bt-device-identity ADR-6): they are transient
|
||||||
|
// fallback ids for a device whose real MAC is not yet known, so
|
||||||
|
// persisting a preset entry for them would create dead noise that never
|
||||||
|
// resolves to the eventual real-MAC id.
|
||||||
final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno;
|
final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno;
|
||||||
if (!esBase && !_presetsDispositivo.containsKey(dispositivo.id)) {
|
final esPlaceholderCompuesto = dispositivo.id.startsWith(
|
||||||
|
_prefijoPlaceholderCompuesto,
|
||||||
|
);
|
||||||
|
if (!esBase &&
|
||||||
|
!esPlaceholderCompuesto &&
|
||||||
|
!_presetsDispositivo.containsKey(dispositivo.id)) {
|
||||||
final presetBase = _resolverPresetActivo();
|
final presetBase = _resolverPresetActivo();
|
||||||
_presetsDispositivo[dispositivo.id] = presetBase;
|
_presetsDispositivo[dispositivo.id] = presetBase;
|
||||||
await servicio.guardarPresetDispositivo(dispositivo.id, presetBase);
|
await servicio.guardarPresetDispositivo(dispositivo.id, presetBase);
|
||||||
@@ -252,6 +277,21 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
if (notificar) notifyListeners();
|
if (notificar) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requests `BLUETOOTH_CONNECT` (API 31+) at the point the
|
||||||
|
/// device-management UI is opened (bt-device-identity ADR-1).
|
||||||
|
///
|
||||||
|
/// Thin passthrough to the injected device service so callers don't need
|
||||||
|
/// [ServicioDispositivoAudio] wired as its own top-level `Provider` (Task
|
||||||
|
/// 4.8 — `ServicioDispositivoAudio` is only ever constructed inside
|
||||||
|
/// `EstadoRadio` today, not exposed via the provider tree; routing through
|
||||||
|
/// here avoids adding wiring the tree doesn't already have). Returns false
|
||||||
|
/// when no device service is injected.
|
||||||
|
Future<bool> solicitarPermisoBluetooth() async {
|
||||||
|
final svc = _dispositivoAudio;
|
||||||
|
if (svc == null) return false;
|
||||||
|
return svc.solicitarPermisoBluetooth();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> cambiarPresetPrincipal(
|
Future<void> cambiarPresetPrincipal(
|
||||||
PresetEcualizador preset, {
|
PresetEcualizador preset, {
|
||||||
bool notificar = true,
|
bool notificar = true,
|
||||||
@@ -353,6 +393,11 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
String obtenerNombreDispositivo(String deviceId) =>
|
String obtenerNombreDispositivo(String deviceId) =>
|
||||||
_nombresDispositivos[deviceId] ?? '';
|
_nombresDispositivos[deviceId] ?? '';
|
||||||
|
|
||||||
|
/// Returns the last-seen platform name for [deviceId], or an empty string
|
||||||
|
/// if none has been observed yet (bt-device-identity ADR-4).
|
||||||
|
String nombrePlataforma(String deviceId) =>
|
||||||
|
_nombresPlataforma[deviceId] ?? '';
|
||||||
|
|
||||||
/// Resolves the display name for [deviceId] using the fallback chain:
|
/// Resolves the display name for [deviceId] using the fallback chain:
|
||||||
/// custom name → [platformName] → raw [deviceId].
|
/// custom name → [platformName] → raw [deviceId].
|
||||||
String nombreVisible(String deviceId, String platformName) {
|
String nombreVisible(String deviceId, String platformName) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -698,7 +699,8 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap:
|
onTap:
|
||||||
() => eq.cambiarMultiDeviceEnabled(!multiDeviceEnabled),
|
() =>
|
||||||
|
_alternarMultiDevice(eq, !multiDeviceEnabled),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -718,7 +720,7 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget {
|
|||||||
Switch.adaptive(
|
Switch.adaptive(
|
||||||
value: multiDeviceEnabled,
|
value: multiDeviceEnabled,
|
||||||
onChanged:
|
onChanged:
|
||||||
(habilitado) => eq.cambiarMultiDeviceEnabled(habilitado),
|
(habilitado) => _alternarMultiDevice(eq, habilitado),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -749,6 +751,17 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggles the multi-device EQ feature and, when turning it ON, requests
|
||||||
|
/// `BLUETOOTH_CONNECT` at this point-of-intent (bt-device-identity ADR-1)
|
||||||
|
/// so BT devices report their real MAC instead of the OS placeholder.
|
||||||
|
/// Fire-and-forget: neither call blocks the toggle UI on its result.
|
||||||
|
void _alternarMultiDevice(EstadoEcualizador eq, bool habilitado) {
|
||||||
|
unawaited(eq.cambiarMultiDeviceEnabled(habilitado));
|
||||||
|
if (habilitado) {
|
||||||
|
unawaited(eq.solicitarPermisoBluetooth());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single device row in the known-devices list.
|
/// A single device row in the known-devices list.
|
||||||
@@ -766,7 +779,7 @@ class _FilaDispositivo extends StatelessWidget {
|
|||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final eq = context.watch<EstadoEcualizador>();
|
final eq = context.watch<EstadoEcualizador>();
|
||||||
final isActive = eq.dispositivoActualId == deviceId;
|
final isActive = eq.dispositivoActualId == deviceId;
|
||||||
final displayName = eq.nombreVisible(deviceId, '');
|
final displayName = eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
@@ -843,7 +856,10 @@ class _DialogoEdicionDispositivoState
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final eq = context.read<EstadoEcualizador>();
|
final eq = context.read<EstadoEcualizador>();
|
||||||
final displayName = eq.nombreVisible(widget.deviceId, '');
|
final displayName = eq.nombreVisible(
|
||||||
|
widget.deviceId,
|
||||||
|
eq.nombrePlataforma(widget.deviceId),
|
||||||
|
);
|
||||||
_nombreCtrl = TextEditingController(text: displayName);
|
_nombreCtrl = TextEditingController(text: displayName);
|
||||||
_presetActual = widget.preset;
|
_presetActual = widget.preset;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ abstract class ServicioDispositivoAudio {
|
|||||||
/// (method channel round-trip). Returns the cached value if already known.
|
/// (method channel round-trip). Returns the cached value if already known.
|
||||||
Future<DispositivoAudio> obtenerDispositivoActual();
|
Future<DispositivoAudio> obtenerDispositivoActual();
|
||||||
|
|
||||||
|
/// Requests the `BLUETOOTH_CONNECT` runtime permission (API 31+) at the
|
||||||
|
/// point the device-management UI is opened (bt-device-identity ADR-1).
|
||||||
|
/// Returns true when granted or not required (SDK < 31, iOS); false when
|
||||||
|
/// denied.
|
||||||
|
Future<bool> solicitarPermisoBluetooth();
|
||||||
|
|
||||||
/// Cancels the device-change subscription and releases resources.
|
/// Cancels the device-change subscription and releases resources.
|
||||||
Future<void> dispose();
|
Future<void> dispose();
|
||||||
}
|
}
|
||||||
@@ -82,6 +88,14 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
|
|||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> solicitarPermisoBluetooth() async {
|
||||||
|
final granted = await _methodChannel.invokeMethod<bool>(
|
||||||
|
'requestBluetoothConnect',
|
||||||
|
);
|
||||||
|
return granted ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _eventSub?.cancel();
|
await _eventSub?.cancel();
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ class ServicioEcualizador {
|
|||||||
static const _keyPresetsMatriz = 'eq_presets_matriz_v1';
|
static const _keyPresetsMatriz = 'eq_presets_matriz_v1';
|
||||||
static const _keyNombresDispositivos = 'eq_nombres_dispositivos_v1';
|
static const _keyNombresDispositivos = 'eq_nombres_dispositivos_v1';
|
||||||
|
|
||||||
|
/// bt-device-identity ADR-5: guard flag for the one-time placeholder purge.
|
||||||
|
static const _keyPlaceholderPurgaHecha = 'eq_placeholder_purge_done_v1';
|
||||||
|
|
||||||
|
/// bt-device-identity ADR-3: the exact OS placeholder MAC, in the
|
||||||
|
/// `bt_a2dp:`-prefixed id shape used across all device-keyed SP maps.
|
||||||
|
static const _placeholderMacLiteral = 'bt_a2dp:02:00:00:00:00:00';
|
||||||
|
|
||||||
final SharedPreferences? _prefs;
|
final SharedPreferences? _prefs;
|
||||||
|
|
||||||
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
|
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
|
||||||
@@ -50,6 +57,7 @@ class ServicioEcualizador {
|
|||||||
_prefs ?? SharedPreferences.getInstance();
|
_prefs ?? SharedPreferences.getInstance();
|
||||||
|
|
||||||
Future<ConfiguracionEcualizador> cargar() async {
|
Future<ConfiguracionEcualizador> cargar() async {
|
||||||
|
await migrarClavesPlaceholder();
|
||||||
final prefs = await _resolverPrefs();
|
final prefs = await _resolverPrefs();
|
||||||
final principal = _leerPresetPrincipal(prefs);
|
final principal = _leerPresetPrincipal(prefs);
|
||||||
final porEmisora = _leerPresetsPorEmisora(prefs);
|
final porEmisora = _leerPresetsPorEmisora(prefs);
|
||||||
@@ -67,6 +75,56 @@ class ServicioEcualizador {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-time guarded migration (bt-device-identity ADR-5): purges entries
|
||||||
|
/// keyed by the exact OS placeholder MAC ([_placeholderMacLiteral]) from
|
||||||
|
/// all three device-keyed SP maps. Idempotent — short-circuits via
|
||||||
|
/// [_keyPlaceholderPurgaHecha] after the first successful run. Every other
|
||||||
|
/// entry (including near-miss keys) is preserved byte-for-byte.
|
||||||
|
Future<void> migrarClavesPlaceholder() async {
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
if (prefs.getBool(_keyPlaceholderPurgaHecha) ?? false) return;
|
||||||
|
|
||||||
|
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
|
||||||
|
if (presetsPorDispositivo.remove(_placeholderMacLiteral) != null) {
|
||||||
|
await _guardarMapa(
|
||||||
|
prefs,
|
||||||
|
_keyPresetsPorDispositivo,
|
||||||
|
presetsPorDispositivo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrix keys are "stationUuid:deviceId"; split on the FIRST colon only
|
||||||
|
// (station UUIDs are RFC4122 and contain no colons — multi-device-eq
|
||||||
|
// ADR-3), since deviceId itself may contain colons (e.g. a MAC-based id).
|
||||||
|
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
|
||||||
|
final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) {
|
||||||
|
final separador = clave.indexOf(':');
|
||||||
|
if (separador == -1) return false;
|
||||||
|
final deviceIdSegmento = clave.substring(separador + 1);
|
||||||
|
return deviceIdSegmento == _placeholderMacLiteral;
|
||||||
|
}).toList();
|
||||||
|
if (clavesMatrizAPurgar.isNotEmpty) {
|
||||||
|
for (final clave in clavesMatrizAPurgar) {
|
||||||
|
presetsMatriz.remove(clave);
|
||||||
|
}
|
||||||
|
await _guardarMapa(prefs, _keyPresetsMatriz, presetsMatriz);
|
||||||
|
}
|
||||||
|
|
||||||
|
final nombresDispositivos = _leerMapaStrings(
|
||||||
|
prefs,
|
||||||
|
_keyNombresDispositivos,
|
||||||
|
);
|
||||||
|
if (nombresDispositivos.remove(_placeholderMacLiteral) != null) {
|
||||||
|
await _guardarMapaStrings(
|
||||||
|
prefs,
|
||||||
|
_keyNombresDispositivos,
|
||||||
|
nombresDispositivos,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prefs.setBool(_keyPlaceholderPurgaHecha, true);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> guardarPrincipal(PresetEcualizador preset) async {
|
Future<void> guardarPrincipal(PresetEcualizador preset) async {
|
||||||
final prefs = await _resolverPrefs();
|
final prefs = await _resolverPrefs();
|
||||||
await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson()));
|
await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson()));
|
||||||
|
|||||||
@@ -1133,6 +1133,194 @@ void main() {
|
|||||||
// WARNING-3: FakeServicioEcualizador preserves nombresDispositivos
|
// 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
group('FakeServicioEcualizador — nombresDispositivos preservation (WARNING-3)', () {
|
group('FakeServicioEcualizador — nombresDispositivos preservation (WARNING-3)', () {
|
||||||
test(
|
test(
|
||||||
'guardarPrincipal preserves nombresDispositivos',
|
'guardarPrincipal preserves nombresDispositivos',
|
||||||
|
|||||||
@@ -438,6 +438,9 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
|||||||
throw Exception('Platform channel error: device unavailable');
|
throw Exception('Platform channel error: device unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> solicitarPermisoBluetooth() async => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _controller.close();
|
await _controller.close();
|
||||||
@@ -449,6 +452,16 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
|||||||
/// Use [emitirDispositivo] to push device change events without needing
|
/// Use [emitirDispositivo] to push device change events without needing
|
||||||
/// a real platform channel.
|
/// a real platform channel.
|
||||||
class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
||||||
|
FakeServicioDispositivoAudio({this.permisoBluetoothConcedido = true});
|
||||||
|
|
||||||
|
/// Value returned by [solicitarPermisoBluetooth] (bt-device-identity
|
||||||
|
/// Phase 2/4 testing strategy — defaults to granted so existing tests
|
||||||
|
/// that don't care about the permission flow are unaffected).
|
||||||
|
final bool permisoBluetoothConcedido;
|
||||||
|
|
||||||
|
/// Number of times [solicitarPermisoBluetooth] was called.
|
||||||
|
int solicitarPermisoBluetoothCalls = 0;
|
||||||
|
|
||||||
final _controller = StreamController<DispositivoAudio>.broadcast();
|
final _controller = StreamController<DispositivoAudio>.broadcast();
|
||||||
DispositivoAudio? _dispositivoActual;
|
DispositivoAudio? _dispositivoActual;
|
||||||
|
|
||||||
@@ -468,6 +481,12 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> solicitarPermisoBluetooth() async {
|
||||||
|
solicitarPermisoBluetoothCalls++;
|
||||||
|
return permisoBluetoothConcedido;
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper for tests: push a device change event.
|
/// Helper for tests: push a device change event.
|
||||||
void emitirDispositivo(DispositivoAudio dispositivo) {
|
void emitirDispositivo(DispositivoAudio dispositivo) {
|
||||||
_dispositivoActual = dispositivo;
|
_dispositivoActual = dispositivo;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ void main() {
|
|||||||
Future<EstadoRadio> crearEstado({
|
Future<EstadoRadio> crearEstado({
|
||||||
bool eqMultiDeviceEnabled = false,
|
bool eqMultiDeviceEnabled = false,
|
||||||
Map<String, PresetEcualizador> presetsDispositivo = const {},
|
Map<String, PresetEcualizador> presetsDispositivo = const {},
|
||||||
|
FakeServicioDispositivoAudio? dispositivoAudio,
|
||||||
}) async {
|
}) async {
|
||||||
final estado = EstadoRadio(
|
final estado = EstadoRadio(
|
||||||
audio: FakeServicioAudio(),
|
audio: FakeServicioAudio(),
|
||||||
@@ -76,6 +77,7 @@ void main() {
|
|||||||
servicioGrabacion: _FakeGrabacion(),
|
servicioGrabacion: _FakeGrabacion(),
|
||||||
resolverArchivoCustom: _archivoCustomVacio,
|
resolverArchivoCustom: _archivoCustomVacio,
|
||||||
iniciarAutomaticamente: false,
|
iniciarAutomaticamente: false,
|
||||||
|
dispositivoAudio: dispositivoAudio,
|
||||||
);
|
);
|
||||||
await estado.ecualizador.cargarPersistido();
|
await estado.ecualizador.cargarPersistido();
|
||||||
return estado;
|
return estado;
|
||||||
@@ -99,6 +101,7 @@ void main() {
|
|||||||
Map<String, PresetEcualizador>? presetsDispositivo,
|
Map<String, PresetEcualizador>? presetsDispositivo,
|
||||||
Map<String, String>? nombresDispositivos,
|
Map<String, String>? nombresDispositivos,
|
||||||
String? activeDeviceId,
|
String? activeDeviceId,
|
||||||
|
String nombrePlataforma = 'BT Speaker',
|
||||||
}) async {
|
}) async {
|
||||||
final fakeDispositivo = activeDeviceId != null
|
final fakeDispositivo = activeDeviceId != null
|
||||||
? (FakeServicioDispositivoAudio()
|
? (FakeServicioDispositivoAudio()
|
||||||
@@ -106,7 +109,7 @@ void main() {
|
|||||||
DispositivoAudio(
|
DispositivoAudio(
|
||||||
id: activeDeviceId,
|
id: activeDeviceId,
|
||||||
tipo: TipoDispositivo.bluetoothA2dp,
|
tipo: TipoDispositivo.bluetoothA2dp,
|
||||||
nombre: 'BT Speaker',
|
nombre: nombrePlataforma,
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
: null;
|
: null;
|
||||||
@@ -380,6 +383,134 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── bt-device-identity Phase 4: display fix + permission trigger ─────────
|
||||||
|
|
||||||
|
group('_SeccionEcualizadorAvanzado — bt-device-identity Phase 4', () {
|
||||||
|
// 4.1 — new behavior: the cached platform name (not '') now feeds
|
||||||
|
// nombreVisible, so a device with no custom rename shows its real name.
|
||||||
|
testWidgets('4.1 platform name displays with no custom rename', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
||||||
|
final estado = await crearEstadoConNombres(
|
||||||
|
presetsDispositivo: {deviceId: PresetEcualizador.rock},
|
||||||
|
activeDeviceId: deviceId,
|
||||||
|
nombrePlataforma: 'AirPods Pro',
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildAjustes(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Known audio devices'),
|
||||||
|
300,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('AirPods Pro'), findsOneWidget);
|
||||||
|
expect(find.text(deviceId), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4.4 — triangulation companion: custom rename still wins even though
|
||||||
|
// the row now also has a cached platform name available.
|
||||||
|
testWidgets('4.4 custom rename overrides platform name', (tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
||||||
|
final estado = await crearEstadoConNombres(
|
||||||
|
presetsDispositivo: {deviceId: PresetEcualizador.rock},
|
||||||
|
activeDeviceId: deviceId,
|
||||||
|
nombrePlataforma: 'AirPods Pro',
|
||||||
|
nombresDispositivos: {deviceId: 'My Headphones'},
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildAjustes(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Known audio devices'),
|
||||||
|
300,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('My Headphones'), findsOneWidget);
|
||||||
|
expect(find.text('AirPods Pro'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4.5 — approval test (regression-lock): a device never seen on the
|
||||||
|
// stream has no cached platform name, so legacy raw-id fallback holds.
|
||||||
|
testWidgets('4.5 no platform name yet falls back to raw id', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
||||||
|
final estado = await crearEstadoConNombres(
|
||||||
|
presetsDispositivo: {deviceId: PresetEcualizador.rock},
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildAjustes(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Known audio devices'),
|
||||||
|
300,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text(deviceId), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4.6 — permission trigger point: turning the toggle ON requests
|
||||||
|
// BLUETOOTH_CONNECT; turning it back OFF must not re-fire the request.
|
||||||
|
testWidgets('4.6 permission call fires on device-management open', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final fakeDispositivo = FakeServicioDispositivoAudio();
|
||||||
|
final estado = await crearEstado(
|
||||||
|
eqMultiDeviceEnabled: false,
|
||||||
|
dispositivoAudio: fakeDispositivo,
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildAjustes(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Advanced Equalization Options'),
|
||||||
|
300,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(0));
|
||||||
|
|
||||||
|
// Toggle ON: permission requested exactly once.
|
||||||
|
await tester.tap(find.byType(Switch).last);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue);
|
||||||
|
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1));
|
||||||
|
|
||||||
|
// Toggle OFF again: no further permission request.
|
||||||
|
await tester.tap(find.byType(Switch).last);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse);
|
||||||
|
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Infrastructure ──────────────────────────────────────────────────────────
|
// ── Infrastructure ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ void main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void stubRequestBluetoothConnect(bool? response) {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(const MethodChannel(methodChannelName), (
|
||||||
|
call,
|
||||||
|
) async {
|
||||||
|
if (call.method == 'requestBluetoothConnect') return response;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('obtenerDispositivoActual maps builtin_speaker (type=2)', () async {
|
test('obtenerDispositivoActual maps builtin_speaker (type=2)', () async {
|
||||||
stubGetActiveDevice({
|
stubGetActiveDevice({
|
||||||
'id': 'builtin_speaker',
|
'id': 'builtin_speaker',
|
||||||
@@ -105,5 +115,36 @@ void main() {
|
|||||||
test('onDispositivoCambiado is a broadcast stream', () {
|
test('onDispositivoCambiado is a broadcast stream', () {
|
||||||
expect(servicio.onDispositivoCambiado.isBroadcast, isTrue);
|
expect(servicio.onDispositivoCambiado.isBroadcast, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── bt-device-identity Phase 2: permission contract ──────────────────────
|
||||||
|
|
||||||
|
test(
|
||||||
|
'solicitarPermisoBluetooth invokes requestBluetoothConnect and '
|
||||||
|
'returns true when granted',
|
||||||
|
() async {
|
||||||
|
stubRequestBluetoothConnect(true);
|
||||||
|
final granted = await servicio.solicitarPermisoBluetooth();
|
||||||
|
expect(granted, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'solicitarPermisoBluetooth returns false when the platform denies',
|
||||||
|
() async {
|
||||||
|
stubRequestBluetoothConnect(false);
|
||||||
|
final granted = await servicio.solicitarPermisoBluetooth();
|
||||||
|
expect(granted, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'solicitarPermisoBluetooth returns false when the platform channel '
|
||||||
|
'returns null',
|
||||||
|
() async {
|
||||||
|
stubRequestBluetoothConnect(null);
|
||||||
|
final granted = await servicio.solicitarPermisoBluetooth();
|
||||||
|
expect(granted, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,5 +78,17 @@ void main() {
|
|||||||
test('dispose closes the stream without error', () async {
|
test('dispose closes the stream without error', () async {
|
||||||
await expectLater(fake.dispose(), completes);
|
await expectLater(fake.dispose(), completes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// bt-device-identity Task 2.5: interface-completeness — every concrete
|
||||||
|
// ServicioDispositivoAudio must implement solicitarPermisoBluetooth.
|
||||||
|
test(
|
||||||
|
'solicitarPermisoBluetooth increments the call counter and returns '
|
||||||
|
'the configured value',
|
||||||
|
() async {
|
||||||
|
final granted = await fake.solicitarPermisoBluetooth();
|
||||||
|
expect(granted, isTrue);
|
||||||
|
expect(fake.solicitarPermisoBluetoothCalls, equals(1));
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class NullServicioDispositivoAudio extends ServicioDispositivoAudio {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> solicitarPermisoBluetooth() async => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {}
|
Future<void> dispose() async {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,4 +229,181 @@ void main() {
|
|||||||
expect(restored.nombresDispositivos['bt_a2dp:AA:BB'], equals('My Speaker'));
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user