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 _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 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), ); }); }); }