Introduce multi-device EQ support allowing each audio output device (built-in speaker, wired, USB, individual Bluetooth by MAC) to have its own equalizer preset, combined with existing per-station presets for a full station×device matrix. - Add DispositivoAudio model and ServicioDispositivoAudio interface - Add Android platform channel (AudioDeviceCallback) for device detection - Add iOS AudioDevicesPlugin (AVAudioSession route tracking) - Extend ServicioEcualizador with device and matrix persistence keys - Implement 4-level resolution: matrix > station > device > global - Add advanced EQ settings section with feature toggle (off by default) - Extend export/import to v3 with backward compatibility - 184 tests passing, zero analyzer issues
236 lines
8.5 KiB
Dart
236 lines
8.5 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/modelos/emisora.dart';
|
|
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
|
import 'package:pluriwave/servicios/servicio_export_import.dart';
|
|
|
|
void main() {
|
|
const servicio = ServicioExportImport();
|
|
|
|
// Shared fixture data used across multiple tests.
|
|
const gruposFavoritosFixture = [
|
|
GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1),
|
|
GrupoFavoritos(
|
|
id: GrupoFavoritos.sinAsignarId,
|
|
nombre: 'Sin asignar',
|
|
orden: 0,
|
|
protegido: true,
|
|
),
|
|
];
|
|
const favoritosFixture = [
|
|
Emisora(
|
|
uuid: 'fav-1',
|
|
nombre: 'Radio Uno',
|
|
url: 'https://uno.example/stream',
|
|
grupoFavoritosId: 'g1',
|
|
bitrate: 192,
|
|
),
|
|
];
|
|
const emisorasCustomFixture = [
|
|
Emisora(
|
|
uuid: 'custom-1',
|
|
nombre: 'Mi Radio',
|
|
url: 'https://mia.example/stream',
|
|
),
|
|
];
|
|
const alarmasFixture = {
|
|
'alarmas': [
|
|
{'id': 'a1', 'hora': 7, 'minuto': 30, 'activa': true, 'snoozeMinutos': 5},
|
|
],
|
|
'vacaciones': [
|
|
{'desde': '2026-07-01', 'hasta': '2026-07-15'},
|
|
],
|
|
};
|
|
|
|
group('ServicioExportImport (S4-R4)', () {
|
|
test('v2 round-trip: exportar then importar yields a deep-equal config '
|
|
'(S4-R4-A, S6-R2 test #4)', () {
|
|
final config = servicio.construirExportacion(
|
|
gruposFavoritos: gruposFavoritosFixture,
|
|
favoritos: favoritosFixture,
|
|
emisorasCustom: emisorasCustomFixture,
|
|
presetPrincipal: PresetEcualizador.jazz,
|
|
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
|
|
// Raw alarm block exactly as ServicioAlarmas persists it (alarms +
|
|
// vacations + exceptions) — must pass through untouched.
|
|
alarmas: alarmasFixture,
|
|
emisoraPreferidaUuid: 'fav-1',
|
|
ordenListas: 'calidad',
|
|
timerSuenoPresetsSegundos: const [300, 600, 1800],
|
|
);
|
|
|
|
final json = servicio.exportar(config);
|
|
final importado = servicio.importar(json);
|
|
|
|
expect(importado, isNotNull);
|
|
expect(importado, equals(config));
|
|
expect(importado!['version'], 2);
|
|
// Alarm raw-JSON passthrough survives the round-trip intact.
|
|
expect(importado['alarmas'], equals(config['alarmas']));
|
|
// The protected "sin asignar" group is never exported.
|
|
final grupos = importado['gruposFavoritos'] as List;
|
|
expect(grupos, hasLength(1));
|
|
expect((grupos.single as Map)['id'], 'g1');
|
|
});
|
|
|
|
test('malformed JSON returns null without throwing (S4-R4)', () {
|
|
expect(servicio.importar('{not valid json'), isNull);
|
|
expect(servicio.importar(''), isNull);
|
|
// Valid JSON but not an object — also rejected gracefully.
|
|
expect(servicio.importar('[1, 2, 3]'), isNull);
|
|
});
|
|
});
|
|
|
|
group('ServicioExportImport v3 — multi-device EQ (Phase 6)', () {
|
|
test(
|
|
'6.1-A: v3 export includes schemaVersion 3 and device/matrix preset keys',
|
|
() {
|
|
final presetDispositivo = PresetEcualizador.rock;
|
|
final presetMatriz = PresetEcualizador.jazz;
|
|
|
|
final config = servicio.construirExportacion(
|
|
gruposFavoritos: gruposFavoritosFixture,
|
|
favoritos: favoritosFixture,
|
|
emisorasCustom: emisorasCustomFixture,
|
|
presetPrincipal: PresetEcualizador.flat,
|
|
presetsPorEmisora: {},
|
|
alarmas: null,
|
|
emisoraPreferidaUuid: null,
|
|
ordenListas: 'calidad',
|
|
timerSuenoPresetsSegundos: const [300],
|
|
presetsPorDispositivo: {
|
|
'bt_a2dp:AA:BB:CC:DD:EE:FF': presetDispositivo,
|
|
},
|
|
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': presetMatriz},
|
|
eqMultiDeviceEnabled: true,
|
|
);
|
|
|
|
// v3 schema version.
|
|
expect(config['version'], 3);
|
|
|
|
// Device preset map present and correct.
|
|
expect(config['presetsPorDispositivo'], isA<Map>());
|
|
final dispositivoMap =
|
|
config['presetsPorDispositivo'] as Map<String, dynamic>;
|
|
expect(dispositivoMap, contains('bt_a2dp:AA:BB:CC:DD:EE:FF'));
|
|
|
|
// Matrix preset map present and correct.
|
|
expect(config['presetsMatriz'], isA<Map>());
|
|
final matrizMap = config['presetsMatriz'] as Map<String, dynamic>;
|
|
expect(matrizMap, contains('fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'));
|
|
|
|
// Toggle exported.
|
|
expect(config['eqMultiDeviceEnabled'], isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'6.1-B: v3 round-trip preserves device and matrix presets exactly',
|
|
() {
|
|
final dispositivoPreset = PresetEcualizador.rock;
|
|
final matrizPreset = PresetEcualizador.jazz;
|
|
|
|
final config = servicio.construirExportacion(
|
|
gruposFavoritos: gruposFavoritosFixture,
|
|
favoritos: favoritosFixture,
|
|
emisorasCustom: emisorasCustomFixture,
|
|
presetPrincipal: PresetEcualizador.flat,
|
|
presetsPorEmisora: {'fav-1': PresetEcualizador.pop},
|
|
alarmas: alarmasFixture,
|
|
emisoraPreferidaUuid: 'fav-1',
|
|
ordenListas: 'calidad',
|
|
timerSuenoPresetsSegundos: const [300, 1800],
|
|
presetsPorDispositivo: {
|
|
'bt_a2dp:AA:BB:CC:DD:EE:FF': dispositivoPreset,
|
|
'wired_headset': PresetEcualizador.flat,
|
|
},
|
|
presetsMatriz: {'fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF': matrizPreset},
|
|
eqMultiDeviceEnabled: true,
|
|
);
|
|
|
|
final json = servicio.exportar(config);
|
|
final importado = servicio.importar(json);
|
|
|
|
expect(importado, isNotNull);
|
|
expect(importado!['version'], 3);
|
|
|
|
// Device presets survive the round-trip.
|
|
final deviceMap =
|
|
importado['presetsPorDispositivo'] as Map<String, dynamic>;
|
|
expect(
|
|
deviceMap['bt_a2dp:AA:BB:CC:DD:EE:FF'],
|
|
equals(dispositivoPreset.toJson()),
|
|
);
|
|
expect(
|
|
deviceMap['wired_headset'],
|
|
equals(PresetEcualizador.flat.toJson()),
|
|
);
|
|
|
|
// Matrix presets survive the round-trip.
|
|
final matrizMap = importado['presetsMatriz'] as Map<String, dynamic>;
|
|
expect(
|
|
matrizMap['fav-1:bt_a2dp:AA:BB:CC:DD:EE:FF'],
|
|
equals(matrizPreset.toJson()),
|
|
);
|
|
|
|
// Toggle survives.
|
|
expect(importado['eqMultiDeviceEnabled'], isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'6.1-C: v2 file (no device fields) imports cleanly — missing keys default to empty maps',
|
|
() {
|
|
// Simulate a v2 export file (no presetsPorDispositivo / presetsMatriz).
|
|
final v2Config = servicio.construirExportacion(
|
|
gruposFavoritos: gruposFavoritosFixture,
|
|
favoritos: favoritosFixture,
|
|
emisorasCustom: emisorasCustomFixture,
|
|
presetPrincipal: PresetEcualizador.jazz,
|
|
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
|
|
alarmas: null,
|
|
emisoraPreferidaUuid: null,
|
|
ordenListas: 'calidad',
|
|
timerSuenoPresetsSegundos: const [300],
|
|
// No device fields — emulates a v2 export from the old app.
|
|
);
|
|
|
|
// Verify the export has no device keys (a v2 file would not).
|
|
expect(v2Config.containsKey('presetsPorDispositivo'), isFalse);
|
|
expect(v2Config.containsKey('presetsMatriz'), isFalse);
|
|
expect(v2Config.containsKey('eqMultiDeviceEnabled'), isFalse);
|
|
|
|
// Import it — parsing should succeed.
|
|
final json = servicio.exportar(v2Config);
|
|
final importado = servicio.importar(json);
|
|
expect(importado, isNotNull);
|
|
|
|
// Missing device keys should gracefully resolve to null (caller uses ?? {}).
|
|
expect(importado!['presetsPorDispositivo'], isNull);
|
|
expect(importado['presetsMatriz'], isNull);
|
|
expect(importado['eqMultiDeviceEnabled'], isNull);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'6.1-D: unknown schemaVersion (v4-future) does not crash importar()',
|
|
() {
|
|
// Simulate a future v4 export (unknown version, extra fields).
|
|
final futurista = <String, dynamic>{
|
|
'version': 99,
|
|
'presetsPorDispositivo': <String, dynamic>{},
|
|
'presetsMatriz': <String, dynamic>{},
|
|
'unknownFutureField': 'some-value',
|
|
};
|
|
final json = servicio.exportar(futurista);
|
|
// importar() should parse without throwing — version checking is the
|
|
// caller's responsibility (EstadoRadio.importarConfig).
|
|
expect(() => servicio.importar(json), returnsNormally);
|
|
final importado = servicio.importar(json);
|
|
expect(importado, isNotNull);
|
|
expect(importado!['version'], 99);
|
|
},
|
|
);
|
|
});
|
|
}
|