From f2f706b3429c2ae762de997de661f266f8873138 Mon Sep 17 00:00:00 2001 From: freetlab Date: Mon, 3 Aug 2026 21:32:09 +0200 Subject: [PATCH] fix(auto): restore the equalizer toggle and list the user's own presets Two Android Auto regressions reported from the car. 1. The on/off equalizer action disappeared from the playback screen. That was self-inflicted: commit cacd3ec removed it on the theory that a custom action in `controls` aborts `AudioService.setState` and kills the media notification. Reading the plugin source refutes it. setState (AudioService.java:513-520) SPLITS the list -- a control carrying a customAction goes to `customActions` (PlaybackStateCompat, i.e. the car), everything else becomes a NotificationCompat.Action in `nativeActions` (the phone notification). The two never mix. And the throw the theory depended on cannot happen here: ic_auto_eq_on/ic_auto_eq_off both exist under res/drawable, and the labels are non-empty in all 13 locales. The notification outage was already fixed by abc6b47 (transient idle on a source change, which setState turns into a full stop() at :557). The action is back, with both state-aware icons. The real invariant -- a custom action's icon must resolve and its label must be non-empty -- is now a test that reads res/drawable and fails on a missing file, instead of a comment claiming custom actions are forbidden outright. 2. The Ecualizador folder never listed the user's saved presets. itemsEcualizadorAuto iterated PresetEcualizador.presets, so only the six factory presets appeared -- the user's own were unreachable from the car, the surface where a preset picker matters most. They now arrive through a registered read function (same seam as stations and local music, re-read per browse so a preset saved on the phone shows up without an app restart). presetsEcualizadorAuto is the single source of truth for the ordered universe, used to BUILD the items and to RESOLVE a tap, so the folder cannot show an item that resolution then refuses -- which is what the factory-only default in seleccionarPresetEqPorMediaId would have caused. A custom preset whose name collides with a factory one is dropped: the media id is the raw name, so it could only ever resolve to the factory entry, and an item that applies a preset other than the one it names is worse than an absent one. Tests: 1108 -> 1120. --- lib/main.dart | 9 + lib/servicios/navegacion_auto.dart | 9 +- lib/servicios/servicio_audio.dart | 147 ++++++++--- ...cio_audio_controles_notificacion_test.dart | 232 +++++++++++++----- .../servicio_audio_eq_folder_test.dart | 131 ++++++++++ 5 files changed, 430 insertions(+), 98 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index a073073..725f856 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'servicios/musica_local_auto.dart'; import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio_session.dart'; +import 'servicios/servicio_presets_personalizados.dart'; import 'tema/pluriwave_tokens.dart'; const _anchoMinimoLandscape = 600.0; @@ -55,6 +56,14 @@ Future main() async { // permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask). registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs)); + // User-saved EQ presets for the car's Ecualizador folder, same + // injectable-prefs DI convention and same pre-init placement as the two + // registrations above (neither depends on the AudioHandler). Passed as a + // read function, not the service, so the folder re-reads on every browse: + // a preset saved on the phone appears in the car without an app restart. + final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs); + registrarFuentePresetsPersonalizados(presetsPersonalizados.listar); + // Silent-error channel (fix/notificacion-media): `AudioService.asyncError` // had ZERO subscribers app-wide, and a `PublishSubject` with no listeners // drops what it is given — so every exception `audio_service` catches diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 1965e93..4df7555 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -954,11 +954,18 @@ Future reproducirPorMediaId( /// A stale/unresolvable id, or any id that doesn't match /// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback /// runs and no exception propagates. +/// +/// [presets] is the universe the id is resolved against, and it MUST be the +/// same list the folder was rendered from (`presetsEcualizadorAuto` in +/// `servicio_audio.dart` — factory presets plus the user's saved ones). +/// Defaulting to the factory six alone is what made a tapped custom preset a +/// silent no-op: the item was listed, but nothing here could resolve it. Future seleccionarPresetEqPorMediaId( String id, { required bool activo, required Future Function(PresetEcualizador) aplicarPreset, required Future Function(bool) activarEcualizador, + List? presets, }) async { final constructor = ConstructorArbolAuto(); if (!constructor.esPresetEqMediaId(id)) return; @@ -968,7 +975,7 @@ Future seleccionarPresetEqPorMediaId( return; } - final preset = constructor.resolverPresetEq(id); + final preset = constructor.resolverPresetEq(id, presets: presets); if (preset == null) return; await aplicarPreset(preset); diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 895ca61..45535ea 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -60,6 +60,70 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) { _fuenteMusicaLocalGlobal = fuente; } +/// User-saved EQ presets browse source — registered from main.dart, mirrors +/// the two registrations above. +/// +/// On-device feedback: the car's `Ecualizador` folder only ever listed the +/// six FACTORY presets, so a driver who had carefully saved their own could +/// not reach it from the car at all — the one place a preset picker is most +/// useful. The handler owns no persistence, so the list arrives through this +/// seam exactly like stations and local music do. +/// +/// A function rather than the service object: the folder needs a fresh read +/// on every browse (a preset saved on the phone must appear in the car +/// without an app restart), and this keeps `servicio_audio.dart` from +/// importing the persistence layer. `null` until registered (headless cold +/// bind) — consumers fall back to factory presets only, never throw. +Future> Function()? _fuentePresetsPersonalizadosGlobal; + +void registrarFuentePresetsPersonalizados( + Future> Function() fuente, +) { + _fuentePresetsPersonalizadosGlobal = fuente; +} + +/// Reads the registered custom-preset source, tolerating both "never +/// registered" and "the read blew up" as the same empty result: a +/// diagnostics-grade failure must degrade the folder to its factory presets, +/// never make browsing fail. +Future> _leerPresetsPersonalizados() async { + final fuente = _fuentePresetsPersonalizadosGlobal; + if (fuente == null) return const []; + try { + return await fuente(); + } catch (_) { + return const []; + } +} + +/// The full ordered preset universe the car's `Ecualizador` folder offers: +/// the six factory presets first, then [personalizados] in save order. +/// +/// A custom preset whose `nombre` matches a factory preset is DROPPED, not +/// appended. Identity here is the raw name — [ConstructorArbolAuto.idPresetEq] +/// builds `eq_preset:` from it and +/// [ConstructorArbolAuto.resolverPresetEq] resolves by first name match — so +/// two entries sharing a name would produce one media id that can only ever +/// reach the first of them. Rendering an item that silently applies a +/// different preset than the one whose name it shows is worse than not +/// rendering it, and the factory entry is the one the id is guaranteed to +/// resolve to. +/// +/// Single source of truth on purpose: [itemsEcualizadorAuto] builds the items +/// from this list and the tap dispatch resolves against this same list, so +/// the folder can never show an item that resolution then refuses. +List presetsEcualizadorAuto({ + required List personalizados, + List? deFabrica, +}) { + final fabrica = deFabrica ?? PresetEcualizador.presets; + final nombresFabrica = fabrica.map((p) => p.nombre).toSet(); + return [ + ...fabrica, + ...personalizados.where((p) => !nombresFabrica.contains(p.nombre)), + ]; +} + /// Teardown hook for whatever `main.dart` wired around the handler and must /// be undone when the handler itself dies — today only the /// `AudioService.asyncError` subscription (`observarErroresAudio`). Registered @@ -313,10 +377,17 @@ String _marcarActivoEq(String titulo, {required bool activo}) => /// since [presetActual] always originates from [PresetEcualizador.presets] /// or a "Personalizado" tweak that would just leave every item unmarked /// rather than mis-marking one). +/// +/// [presetsPersonalizados] are the user's own saved presets, appended after +/// the factory six by [presetsEcualizadorAuto] (which also settles name +/// collisions). They render through the same [nombrePresetVisible] call as +/// everything else: that helper passes an unrecognized name through +/// verbatim, which is exactly right for a name the user typed themselves. List itemsEcualizadorAuto({ required bool activo, required PresetEcualizador presetActual, required AppLocalizations l10n, + List presetsPersonalizados = const [], }) { final constructor = ConstructorArbolAuto(); final items = [ @@ -327,7 +398,9 @@ List itemsEcualizadorAuto({ extras: _contentStyleListaEq, ), ]; - for (final preset in PresetEcualizador.presets) { + for (final preset in presetsEcualizadorAuto( + personalizados: presetsPersonalizados, + )) { items.add( MediaItem( id: constructor.idPresetEq(preset.nombre), @@ -637,29 +710,28 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext] /// at their existing indices 0-3, so `androidCompactActionIndices` /// (`[colaActiva ? 1 : 0]`) stays correct unchanged. - /// NOTHING custom goes in this list. `controls` feeds BOTH the phone's - /// media notification and the car's playback screen, and the notification - /// is the fragile consumer. /// - /// `AudioService.setState` (AudioService.java:513-520) walks every control - /// through `createCustomAction` BEFORE it reaches - /// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559), - /// which is the ONLY place the notification is ever posted. - /// `createCustomAction` resolves the icon by name via - /// `getResources().getIdentifier(...)` (:415-420) — which returns 0 on a - /// miss — and hands it to `PlaybackStateCompat.CustomAction.Builder`, which - /// throws on a 0 icon or an empty label. A throw there aborts the whole - /// `setState`, so the media session is never published and the - /// notification is never posted: no shade widget, no lock-screen controls, - /// not even the small icon beside the clock. Audio keeps playing, because - /// ExoPlayer runs independently — and until `AudioService.asyncError` got - /// its first subscriber, the exception was swallowed without a log line. + /// A custom action here reaches the CAR ONLY, never the phone notification. + /// `AudioService.setState` (AudioService.java:513-520) splits the list in + /// two: `createCustomAction` returns non-null for a control carrying a + /// `customAction`, and that control goes into `customActions` — which feeds + /// `PlaybackStateCompat` and therefore the car's playback screen. Every + /// other control falls to the `else` branch and becomes a + /// `NotificationCompat.Action` in `nativeActions`, the list the media + /// notification is built from. The two never mix, so the equalizer toggle + /// cannot displace a transport button and cannot shift the indices + /// `androidCompactActionIndices` points at. /// - /// The equalizer toggle that used to be appended here is NOT lost: the - /// Android Auto browse tree has a dedicated `Ecualizador` folder listing - /// `Desactivar` plus every preset by name (`navegacion_auto.dart:342`), - /// which is the idiom Auto is actually designed around — a list for - /// choosing among options, not a stateless icon-only button. + /// THE ONE RULE for anything added here with a `customAction`: its + /// `androidIcon` must name a drawable that really exists, and its `label` + /// must be non-empty in EVERY locale. `getResourceId` (:415-420) resolves + /// the icon by name through `getIdentifier` and yields 0 when it misses, + /// and `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an + /// empty label — a throw at :515 aborts `setState` before + /// `mediaSession.setPlaybackState` (:552), taking the whole media session + /// down with it. `servicio_audio_controles_notificacion_test.dart` holds + /// that line: it reads `android/app/src/main/res/drawable/` and fails if an + /// icon named here has no file behind it. List _controlesTransporte({ required bool colaActiva, required bool playing, @@ -668,16 +740,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler if (playing) MediaControl.pause else MediaControl.play, MediaControl.stop, if (colaActiva) MediaControl.skipToNext, + ..._controlesEqPersonalizados(), ]; - /// Re-pushes `playbackState` with a freshly built controls list. - /// - /// It no longer carries an equalizer action — see [_controlesTransporte] - /// for why nothing custom may ride in `controls` — so this is now only a - /// cheap, idempotent refresh of the transport buttons. Kept because the EQ - /// state-change paths still legitimately want the notification's - /// play/pause/stop row rebuilt from current state, and because removing it - /// would silently change when `playbackState` is pushed. + List _controlesEqPersonalizados() => + controlesEcualizadorPersonalizados( + disponible: _eqDisponible, + activo: _ecualizadorActivo, + l10n: _textos, + ); + + /// Re-pushes `playbackState` with a freshly built controls list (item 4): + /// called whenever EQ availability/enabled state changes outside a + /// player-state transition (a custom-action tap, or a phone-side toggle), + /// so the equalizer action's icon and label stay in sync on the car's + /// now-playing screen without waiting for an unrelated player event. + /// Idempotent and cheap (no native calls) — safe to call from any EQ + /// state-changing path. void _actualizarControlesEq() { playbackState.add( playbackState.value.copyWith( @@ -1408,6 +1487,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler activo: _ecualizadorActivo, presetActual: _presetActual, l10n: _textos, + // Read per browse, not cached: a preset saved on the phone must + // show up in the car on the next open, with no app restart. + presetsPersonalizados: await _leerPresetsPersonalizados(), ); } final fuente = _fuenteNavegacionGlobal; @@ -1495,6 +1577,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler activo: _ecualizadorActivo, aplicarPreset: aplicarPreset, activarEcualizador: setEcualizadorActivo, + // The SAME list `itemsEcualizadorAuto` rendered from, so a tapped + // custom preset resolves instead of silently doing nothing. + presets: presetsEcualizadorAuto( + personalizados: await _leerPresetsPersonalizados(), + ), ); return; } diff --git a/test/servicios/servicio_audio_controles_notificacion_test.dart b/test/servicios/servicio_audio_controles_notificacion_test.dart index a037ff6..e6141e0 100644 --- a/test/servicios/servicio_audio_controles_notificacion_test.dart +++ b/test/servicios/servicio_audio_controles_notificacion_test.dart @@ -1,93 +1,191 @@ +import 'dart:io'; +import 'dart:ui' show Locale; + import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; -/// Regression guard for a real, user-reported outage: the media notification -/// vanished entirely — no shade widget, no lock-screen controls, not even the -/// small icon beside the clock — while audio kept playing and nothing was -/// logged. +/// Guards the ONE rule that makes a `MediaControl.custom` safe to put in the +/// handler's transport `controls`. /// -/// Cause: an equalizer `MediaControl.custom(...)` had been appended to the -/// handler's transport `controls`. That list feeds BOTH the phone's media -/// notification and the car's playback screen, and -/// `AudioService.setState` (AudioService.java:513-520) walks every control -/// through `createCustomAction` BEFORE reaching +/// `AudioService.setState` (AudioService.java:513-520) splits `controls` in +/// two: a control carrying a `customAction` becomes a +/// `PlaybackStateCompat.CustomAction` (the CAR's playback screen), everything +/// else becomes a `NotificationCompat.Action` (the PHONE's media +/// notification). The two lists never mix — so a custom action can neither +/// displace a transport button nor shift the indices +/// `androidCompactActionIndices` points at. +/// +/// What it CAN do is take the whole media session down. `getResourceId` +/// (:415-420) resolves `androidIcon` by NAME through +/// `getResources().getIdentifier(...)` and returns 0 when it misses, and +/// `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an empty +/// label. That throw happens at :515, BEFORE /// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559) — -/// the only place the notification is ever posted. `createCustomAction` -/// resolves the icon by NAME via `getResources().getIdentifier(...)` -/// (:415-420), which returns 0 on a miss, and hands it to -/// `PlaybackStateCompat.CustomAction.Builder`, which throws on a 0 icon or an -/// empty label. That throw aborts the whole `setState`, so the session is -/// never published and the notification is never posted. ExoPlayer runs -/// independently, so audio carries on; and until `AudioService.asyncError` -/// got its first subscriber, the exception was dropped without a trace. +/// so the session is never published and the notification is never posted, +/// while ExoPlayer keeps playing regardless. /// -/// The equalizer is NOT lost from the car: the Android Auto browse tree has -/// a dedicated `Ecualizador` folder listing `Desactivar` plus every preset by -/// name (`navegacion_auto.dart:342`) — a list, which is the idiom Auto is -/// designed around for choosing among options. -/// -/// `PluriWaveAudioHandler` cannot be instantiated in a unit test (a real -/// `just_audio.AudioPlayer` needs platform MethodChannels), so this asserts -/// on `MediaControl`'s own public shape: a custom action is exactly a control -/// carrying a non-null `customAction`. Anything appended to the transport row -/// that trips that predicate would reintroduce the outage. +/// A missing drawable is therefore a runtime-only failure on a real head +/// unit: invisible to the analyzer, invisible to a widget test, and silent +/// unless someone is subscribed to `AudioService.asyncError`. The icon +/// existence check below is the whole point of this file — it turns "renamed +/// or deleted a drawable" from a field report into a CI failure. void main() { - group('transport controls must never carry a custom action', () { - /// Mirrors `_controlesTransporte`'s construction exactly. Kept in the - /// test rather than reaching into the private member so the assertion - /// documents the intended shape independently of the implementation. + /// Resolves an `androidIcon` string (`'drawable/ic_foo'`) the same way + /// `getResourceId` does: type directory, then resource name. Any file + /// extension counts — a vector `.xml` and a raster `.png` are equally valid + /// to `getIdentifier`. + bool recursoAndroidExiste(String androidIcon) { + final partes = androidIcon.split('/'); + if (partes.length != 2) return false; + final dir = Directory('android/app/src/main/res/${partes[0]}'); + if (!dir.existsSync()) return false; + return dir.listSync().whereType().any((f) { + final nombre = f.uri.pathSegments.last; + final base = + nombre.contains('.') + ? nombre.substring(0, nombre.indexOf('.')) + : nombre; + return base == partes[1]; + }); + } + + group('equalizer custom action', () { + test('sanity: the resource resolver rejects a drawable that is absent', () { + // Without this, a resolver bug that returns `true` unconditionally + // would make every assertion below vacuous. + expect(recursoAndroidExiste('drawable/ic_no_existe_de_verdad'), isFalse); + expect(recursoAndroidExiste('drawable/ic_stat_pluriwave'), isTrue); + }); + + for (final activo in [false, true]) { + test('icon resolves to a real drawable when activo=$activo', () { + final controles = controlesEcualizadorPersonalizados( + disponible: true, + activo: activo, + l10n: lookupAppLocalizations(const Locale('es')), + ); + + expect(controles, hasLength(1)); + final icono = controles.single.androidIcon; + expect( + recursoAndroidExiste(icono), + isTrue, + reason: + '$icono has no file in android/app/src/main/res/. ' + 'getResourceId would return 0 and CustomAction.Builder would ' + 'throw, aborting setState before the media session is ever ' + 'published — no notification, no car controls, audio still ' + 'playing, nothing logged.', + ); + }); + } + + test('on and off use DISTINCT icons', () { + // On-device feedback: head units render custom actions icon-first, so + // one shared glyph left the driver unable to tell whether the + // equalizer was on. Two identical icons is the bug, not the fix. + MediaControl para({required bool activo}) => + controlesEcualizadorPersonalizados( + disponible: true, + activo: activo, + l10n: lookupAppLocalizations(const Locale('es')), + ).single; + + expect( + para(activo: true).androidIcon, + isNot(para(activo: false).androidIcon), + ); + }); + + test('label is non-empty in every supported locale', () async { + for (final locale in AppLocalizations.supportedLocales) { + final l10n = await AppLocalizations.delegate.load(locale); + for (final activo in [false, true]) { + final control = + controlesEcualizadorPersonalizados( + disponible: true, + activo: activo, + l10n: l10n, + ).single; + expect( + control.label.trim(), + isNotEmpty, + reason: + 'an empty label makes CustomAction.Builder throw for ' + '${locale.languageCode} (activo=$activo), which kills the ' + 'media session for every user in that language', + ); + } + } + }); + + test('no action at all when the device has no equalizer', () { + expect( + controlesEcualizadorPersonalizados( + disponible: false, + activo: true, + l10n: lookupAppLocalizations(const Locale('es')), + ), + isEmpty, + reason: + 'a device without the native effect gets no EQ action, ' + 'never a broken one', + ); + }); + }); + + group('transport row keeps its shape', () { + /// Mirrors `_controlesTransporte`'s construction. Kept in the test rather + /// than reaching into the private member so the assertion documents the + /// intended shape independently of the implementation. List transporte({ required bool colaActiva, required bool playing, + required bool eqDisponible, }) => [ if (colaActiva) MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.stop, if (colaActiva) MediaControl.skipToNext, + ...controlesEcualizadorPersonalizados( + disponible: eqDisponible, + activo: true, + l10n: lookupAppLocalizations(const Locale('es')), + ), ]; for (final colaActiva in [false, true]) { for (final playing in [false, true]) { - test( - 'colaActiva=$colaActiva playing=$playing yields only native actions', - () { - final controles = transporte( - colaActiva: colaActiva, - playing: playing, - ); + test('compact index still points at play/pause ' + '(colaActiva=$colaActiva playing=$playing)', () { + final controles = transporte( + colaActiva: colaActiva, + playing: playing, + eqDisponible: true, + ); - expect( - controles.where((c) => c.customAction != null), - isEmpty, - reason: - 'a custom action here aborts AudioService.setState before ' - 'enterPlayingState(), so no notification is ever posted', - ); + // androidCompactActionIndices is `[colaActiva ? 1 : 0]`. The EQ + // action is APPENDED, so the native transport buttons keep + // indices 0-3 and the collapsed shade still shows play/pause. + final indiceCompacto = colaActiva ? 1 : 0; + expect(controles.length, greaterThan(indiceCompacto)); + expect( + controles[indiceCompacto], + playing ? MediaControl.pause : MediaControl.play, + ); - // androidCompactActionIndices is `[colaActiva ? 1 : 0]`; prove - // that index exists and points at the play/pause button, which is - // what the collapsed shade shows. - final indiceCompacto = colaActiva ? 1 : 0; - expect(controles.length, greaterThan(indiceCompacto)); - expect( - controles[indiceCompacto], - playing ? MediaControl.pause : MediaControl.play, - ); - }, - ); + // The custom action must never sit among the transport buttons: + // `nativeActions` and `customActions` are built by walking this + // list in order, so an interleaved custom action would renumber + // the notification's own actions. + final indiceCustom = controles.indexWhere( + (c) => c.customAction != null, + ); + expect(indiceCustom, controles.length - 1); + }); } } }); - - test('the equalizer control builder still exists for the car, unused by ' - 'the notification', () { - // Deliberately still present and tested: removing it would be the wrong - // lesson. The rule is "not in `controls`", not "never build one". - expect( - controlesEcualizadorPersonalizados, - isNotNull, - reason: 'kept for any future car-only surface that is not `controls`', - ); - }); } diff --git a/test/servicios/servicio_audio_eq_folder_test.dart b/test/servicios/servicio_audio_eq_folder_test.dart index ed9f9f1..14b1423 100644 --- a/test/servicios/servicio_audio_eq_folder_test.dart +++ b/test/servicios/servicio_audio_eq_folder_test.dart @@ -120,4 +120,135 @@ void main() { expect(desactivar.title, contains(l10n.autoEqDisableOption)); }); }); + + /// On-device feedback: the folder only ever listed the six FACTORY + /// presets, so a user's own saved presets were unreachable from the car — + /// the surface where a preset picker matters most, since you cannot pull + /// out the phone and drag five band sliders while driving. + group('presets personalizados del usuario en la carpeta', () { + final mio = PresetEcualizador( + nombre: 'Mi coche', + bandas: [4.0, 2.0, 0.0, 1.0, 3.0], + ); + final otro = PresetEcualizador( + nombre: 'Podcast noche', + bandas: [-3.0, 0.0, 3.0, 2.0, -1.0], + ); + + test('se listan tras los 6 de fábrica, en orden de guardado', () { + final items = itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + presetsPersonalizados: [mio, otro], + ); + + expect(items, hasLength(9)); + expect(items[7].title, contains('Mi coche')); + expect(items[8].title, contains('Podcast noche')); + }); + + test('su id sigue el mismo formato eq_preset: y resuelve al ' + 'preset correcto con la lista combinada', () { + final constructor = ConstructorArbolAuto(); + final universo = presetsEcualizadorAuto(personalizados: [mio]); + final items = itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + presetsPersonalizados: [mio], + ); + + final item = items.last; + expect(item.id, constructor.idPresetEq('Mi coche')); + expect(item.playable, isTrue); + expect(constructor.resolverPresetEq(item.id, presets: universo), mio); + }); + + test('sin la lista combinada NO resuelve: es exactamente el fallo que se ' + 'reportó, un item visible que al pulsarlo no hacía nada', () { + final constructor = ConstructorArbolAuto(); + + // Default `presets` == the factory six only. This is what the tap + // dispatch used to do, and why a listed custom preset was a no-op. + expect( + constructor.resolverPresetEq(constructor.idPresetEq('Mi coche')), + isNull, + ); + }); + + test('pulsar un preset personalizado lo aplica de verdad', () async { + final aplicados = []; + var encendido = false; + + await seleccionarPresetEqPorMediaId( + ConstructorArbolAuto().idPresetEq('Mi coche'), + activo: false, + aplicarPreset: (p) async => aplicados.add(p), + activarEcualizador: (v) async => encendido = v, + presets: presetsEcualizadorAuto(personalizados: [mio]), + ); + + expect(aplicados, [mio]); + // Tapping any preset while the EQ is off must also switch it on, + // exactly as it already does for a factory preset. + expect(encendido, isTrue); + }); + + test('el preset personalizado activo se marca con ✓, y solo él', () { + final items = itemsEcualizadorAuto( + activo: true, + presetActual: mio, + l10n: l10n, + presetsPersonalizados: [mio, otro], + ); + + final marcados = items.where((i) => i.title.contains('✓')).toList(); + expect(marcados, hasLength(1)); + expect(marcados.single.id, ConstructorArbolAuto().idPresetEq('Mi coche')); + }); + + test('un personalizado que repite el nombre de uno de fábrica se DESCARTA ' + '— su id solo podría resolver al de fábrica, así que mostrarlo ' + 'aplicaría un preset distinto del que anuncia', () { + final impostor = PresetEcualizador( + nombre: 'Rock', + bandas: [9.0, 9.0, 9.0, 9.0, 9.0], + ); + + final universo = presetsEcualizadorAuto(personalizados: [impostor, mio]); + + expect(universo.where((p) => p.nombre == 'Rock'), hasLength(1)); + expect( + universo.firstWhere((p) => p.nombre == 'Rock'), + PresetEcualizador.rock, + ); + expect(universo, contains(mio)); + expect( + itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + presetsPersonalizados: [impostor, mio], + ), + hasLength(8), + ); + }); + + test('sin presets guardados la carpeta queda exactamente como antes', () { + expect( + itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + ).map((i) => i.id), + itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + presetsPersonalizados: const [], + ).map((i) => i.id), + ); + }); + }); }