feat(auto): expose EQ presets as a browsable Android Auto folder

Adds an Ecualizador folder listing the 6 fixed presets; selecting one
applies and persists it through the existing headless-safe seam
without touching playback or the now-playing media item.
This commit is contained in:
2026-07-19 14:12:39 +02:00
parent 066fedb7bc
commit 90cd232ad2
9 changed files with 1092 additions and 7 deletions
+223 -3
View File
@@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.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/navegacion_auto.dart';
void main() {
@@ -218,11 +219,11 @@ void main() {
});
group('ConstructorArbolAuto.raiz', () {
test('devuelve exactamente 3 carpetas no reproducibles con los ids '
'esperados', () {
test('devuelve exactamente 4 carpetas no reproducibles con los ids '
'esperados, terminando en Ecualizador', () {
final raiz = ConstructorArbolAuto().raiz();
expect(raiz, hasLength(3));
expect(raiz, hasLength(4));
final ids = raiz.map((item) => item.id).toSet();
expect(
ids,
@@ -230,12 +231,63 @@ void main() {
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idEcualizador,
}),
);
for (final item in raiz) {
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
}
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
expect(raiz.last.playable, isFalse);
});
});
group('esPresetMediaId', () {
test('reconoce ids con el prefijo eq_preset:, rechaza el resto', () {
final casos = <String, bool>{
'eq_preset:Rock': true,
'eq_preset:': true,
'emisora:x': false,
'grupo:g1': false,
'': false,
};
casos.forEach((id, esperado) {
expect(
esPresetMediaId(id),
esperado,
reason: 'esPresetMediaId($id) debería ser $esperado',
);
});
});
});
group('ConstructorArbolAuto.itemPresetEq', () {
test('mapea un PresetEcualizador a un item reproducible con id '
'eq_preset:<nombre>', () {
final item = ConstructorArbolAuto().itemPresetEq(
PresetEcualizador.rock,
);
expect(item.id, 'eq_preset:${PresetEcualizador.rock.nombre}');
expect(item.playable, isTrue);
expect(item.title, PresetEcualizador.rock.nombre);
});
});
group('ConstructorArbolAuto.presetsEq', () {
test('devuelve exactamente 6 items reproducibles, uno por preset', () {
final items = ConstructorArbolAuto().presetsEq(
PresetEcualizador.presets,
);
expect(items, hasLength(6));
for (var i = 0; i < items.length; i++) {
expect(items[i].id, 'eq_preset:${PresetEcualizador.presets[i].nombre}');
expect(items[i].title, PresetEcualizador.presets[i].nombre);
expect(items[i].playable, isTrue);
}
});
});
@@ -564,6 +616,174 @@ void main() {
});
});
group('resolverPresetEq', () {
test('resuelve un nombre conocido al preset exacto', () {
final resultado = resolverPresetEq(
'eq_preset:${PresetEcualizador.rock.nombre}',
PresetEcualizador.presets,
);
expect(resultado, equals(PresetEcualizador.rock));
});
test(
'devuelve null para nombre desconocido, id vacío-tras-prefijo o id sin '
'prefijo eq_preset:, sin lanzar',
() {
expect(
resolverPresetEq('eq_preset:Inexistente', PresetEcualizador.presets),
isNull,
);
expect(
resolverPresetEq('eq_preset:', PresetEcualizador.presets),
isNull,
);
expect(
resolverPresetEq('emisora:x', PresetEcualizador.presets),
isNull,
);
},
);
});
group('debeAplicarPrincipalAhora', () {
test('true cuando no hay estación actual', () {
expect(
debeAplicarPrincipalAhora(
uuidActual: null,
clavesPorEmisora: const {'uuid-otra'},
),
isTrue,
);
});
test('true cuando la estación actual no tiene override por-emisora', () {
expect(
debeAplicarPrincipalAhora(
uuidActual: 'uuid-sin-override',
clavesPorEmisora: const {'uuid-otra'},
),
isTrue,
);
});
test('false cuando la estación actual tiene override por-emisora', () {
expect(
debeAplicarPrincipalAhora(
uuidActual: 'uuid-con-override',
clavesPorEmisora: const {'uuid-con-override'},
),
isFalse,
);
});
});
group('aplicarPresetPorMediaId', () {
test(
'preset conocido: persiste siempre y aplica cuando el gate es true',
() async {
PresetEcualizador? persistido;
PresetEcualizador? aplicado;
await aplicarPresetPorMediaId(
'eq_preset:${PresetEcualizador.rock.nombre}',
presets: PresetEcualizador.presets,
uuidActual: null,
clavesPorEmisora: () async => <String>{},
persistirPrincipal: (preset) async {
persistido = preset;
},
aplicar: (preset) async {
aplicado = preset;
},
);
expect(persistido, equals(PresetEcualizador.rock));
expect(aplicado, equals(PresetEcualizador.rock));
},
);
test(
'preset conocido con override por-emisora en la estación actual: '
'persiste pero NO aplica en vivo (gate false)',
() async {
var persistirLlamadas = 0;
var aplicarLlamadas = 0;
await aplicarPresetPorMediaId(
'eq_preset:${PresetEcualizador.jazz.nombre}',
presets: PresetEcualizador.presets,
uuidActual: 'uuid-con-override',
clavesPorEmisora: () async => {'uuid-con-override'},
persistirPrincipal: (preset) async {
persistirLlamadas++;
},
aplicar: (preset) async {
aplicarLlamadas++;
},
);
expect(persistirLlamadas, 1);
expect(aplicarLlamadas, 0);
},
);
test(
'id desconocido/obsoleto: no persiste ni aplica, no lanza excepción',
() async {
var persistirLlamadas = 0;
var aplicarLlamadas = 0;
await aplicarPresetPorMediaId(
'eq_preset:Inexistente',
presets: PresetEcualizador.presets,
uuidActual: null,
clavesPorEmisora: () async => <String>{},
persistirPrincipal: (preset) async {
persistirLlamadas++;
},
aplicar: (preset) async {
aplicarLlamadas++;
},
);
expect(persistirLlamadas, 0);
expect(aplicarLlamadas, 0);
},
);
test(
'invariante estructural: la firma solo expone seams de '
'persistencia/aplicación EQ, ningun seam de reproducción es '
'inyectable (ADR-3, no-playback-by-construction)',
() async {
// Este test documenta y prueba por construcción que
// aplicarPresetPorMediaId no puede tocar playback: los únicos
// parámetros de función inyectables en su firma son
// persistirPrincipal y aplicar (ambos EQ-only). No existe ningún
// parámetro playMediaItem/mediaItem/playbackState que un caller
// pueda pasar — de haberlo, este call site fallaría a compilar.
final llamadasAplicar = <PresetEcualizador>[];
final llamadasPersistir = <PresetEcualizador>[];
await aplicarPresetPorMediaId(
'eq_preset:${PresetEcualizador.pop.nombre}',
presets: PresetEcualizador.presets,
uuidActual: null,
clavesPorEmisora: () async => <String>{},
persistirPrincipal: (preset) async => llamadasPersistir.add(preset),
aplicar: (preset) async => llamadasAplicar.add(preset),
// NOTE: no `playMediaItem`/`mediaItem`/`playbackState` parameter
// exists on this function — there is nothing to pass here even
// if a caller wanted to. That absence IS the non-playback proof.
);
expect(llamadasPersistir, [PresetEcualizador.pop]);
expect(llamadasAplicar, [PresetEcualizador.pop]);
},
);
});
group('reproducirPorMediaId', () {
test(
'resuelve el id y delega a reproducir con un MediaItem con forma de '