From 90cd232ad29bb852ba55c9b424eeef4c82280687 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sun, 19 Jul 2026 14:12:39 +0200 Subject: [PATCH] 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. --- lib/servicios/navegacion_auto.dart | 94 +++++- lib/servicios/servicio_audio.dart | 26 +- .../android-auto-eq-presets/apply-progress.md | 93 ++++++ .../changes/android-auto-eq-presets/design.md | 279 ++++++++++++++++++ .../android-auto-eq-presets/proposal.md | 67 +++++ .../specs/android-auto-media/spec.md | 115 ++++++++ .../changes/android-auto-eq-presets/tasks.md | 82 +++++ .../android-auto-eq-presets/verify-report.md | 117 ++++++++ test/servicios/navegacion_auto_test.dart | 226 +++++++++++++- 9 files changed, 1092 insertions(+), 7 deletions(-) create mode 100644 openspec/changes/android-auto-eq-presets/apply-progress.md create mode 100644 openspec/changes/android-auto-eq-presets/design.md create mode 100644 openspec/changes/android-auto-eq-presets/proposal.md create mode 100644 openspec/changes/android-auto-eq-presets/specs/android-auto-media/spec.md create mode 100644 openspec/changes/android-auto-eq-presets/tasks.md create mode 100644 openspec/changes/android-auto-eq-presets/verify-report.md diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index a704f55..5013633 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -7,11 +7,22 @@ import 'package:path_provider/path_provider.dart'; import '../estado/orden_emisoras.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; +import '../modelos/preset_ecualizador.dart'; import 'persistencia_tolerante.dart'; import 'servicio_favoritos.dart'; const _prefijoEmisora = 'emisora:'; +/// EQ preset media-id prefix (Design ADR-1), collision-free against +/// [_prefijoEmisora], `grupo:` and the bare folder id constants. +const _prefijoPresetEq = 'eq_preset:'; + +/// Whether [id] identifies an EQ preset leaf item (Design ADR-1). A bare +/// prefix (`'eq_preset:'`, empty name) is still `true` here — the empty-name +/// case is rejected downstream by [resolverPresetEq], not by this routing +/// predicate. +bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq); + /// Canonical on-brand fallback-art names and rotation order, ported /// **verbatim** (same formula, same order) from /// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367) @@ -124,6 +135,12 @@ class ConstructorArbolAuto { static const idTodas = 'todas'; static const idMisEmisoras = 'mis_emisoras'; + /// Root folder id for the EQ presets folder (Design "media-id scheme"). + /// Deliberately NOT added to [_idsCarpetas] — it has its own dedicated + /// branch in `getChildren`/`ConstructorArbolAuto.presetsEq`, not the + /// generic station-list `hijos()` path. + static const idEcualizador = 'ecualizador'; + static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras}; static const _maxItemsPorCarpeta = 50; @@ -147,12 +164,15 @@ class ConstructorArbolAuto { 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2, }; - /// The 3 root folders (Favoritos, Todas las emisoras, Mis emisoras), all - /// non-playable. + /// The 4 root folders (Favoritos, Todas las emisoras, Mis emisoras, + /// Ecualizador), all non-playable. `Ecualizador` is deliberately LAST + /// (Design ADR-2): content-browsing folders are the primary car task and + /// stay first, the EQ tool trails them. List raiz() => [ _carpeta(idFavoritos, 'Favoritos'), _carpeta(idTodas, 'Todas las emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'), + _carpeta(idEcualizador, 'Ecualizador'), ]; MediaItem _carpeta(String id, String titulo) => MediaItem( @@ -209,6 +229,20 @@ class ConstructorArbolAuto { MediaItem itemGrupo(GrupoFavoritos g) => _carpeta('$_prefijoGrupo${g.id}', g.nombre); + /// Maps a [PresetEcualizador] to a playable `MediaItem` with id + /// `eq_preset:` (Design ADR-1). + MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem( + id: '$_prefijoPresetEq${preset.nombre}', + title: preset.nombre, + playable: true, + extras: _contentStyleGrid, + ); + + /// The 6 fixed EQ preset leaf items for the `Ecualizador` folder (Spec + /// "Car requests the Ecualizador folder"). + List presetsEq(List presets) => + presets.map(itemPresetEq).toList(); + /// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as /// direct leaves at the Favoritos root"): non-empty custom-group folders /// (phone order, capped at [_maxGruposPorFavoritos]), followed by @@ -290,6 +324,62 @@ Future reproducirPorMediaId( await reproducir(item); } +/// Resolves an `eq_preset:` [id] to the matching [PresetEcualizador] +/// in [presets] by exact name (Design ADR-1, mirrors +/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix, +/// empty name, unmatched name) returns `null` instead of throwing (Spec +/// "Unknown or stale preset id"). +PresetEcualizador? resolverPresetEq(String id, List presets) { + if (!esPresetMediaId(id)) return null; + final nombre = id.substring(_prefijoPresetEq.length); + if (nombre.isEmpty) return null; + for (final preset in presets) { + if (preset.nombre == nombre) return preset; + } + return null; +} + +/// Pure per-station apply gate (Design ADR-5), mirroring +/// `EstadoEcualizador.cambiarPresetPrincipal`'s exact logic +/// (`estado_ecualizador.dart:302-304`): the new principal preset is applied +/// live when there is no current station ([uuidActual] is `null`) or the +/// current station has no per-station preset override in +/// [clavesPorEmisora]. +bool debeAplicarPrincipalAhora({ + required String? uuidActual, + required Set clavesPorEmisora, +}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual); + +/// Orchestrates an `eq_preset:` selection from the car (Design +/// "Data flow — a preset tap", ADR-3): resolves [id] via [resolverPresetEq], +/// persists it as principal via [persistirPrincipal], and conditionally +/// applies it live via [aplicar] when [debeAplicarPrincipalAhora] allows it. +/// +/// This function's signature exposes ONLY the EQ persist/apply seams — it +/// has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so +/// there is no code path from a preset tap to playback (Design ADR-3, +/// non-playback invariant enforced structurally, not by discipline). An +/// unknown/stale [id] is a no-op: neither seam is invoked and no exception +/// propagates (Spec "Unknown or stale preset id"). +Future aplicarPresetPorMediaId( + String id, { + required List presets, + required String? uuidActual, + required Future> Function() clavesPorEmisora, + required Future Function(PresetEcualizador) persistirPrincipal, + required Future Function(PresetEcualizador) aplicar, +}) async { + final preset = resolverPresetEq(id, presets); + if (preset == null) return; + await persistirPrincipal(preset); + if (debeAplicarPrincipalAhora( + uuidActual: uuidActual, + clavesPorEmisora: await clavesPorEmisora(), + )) { + await aplicar(preset); + } +} + /// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design /// "getChildren data source"). Reads favourites from SQLite and custom /// stations from the tolerant JSON file directly — both loadable without diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 043d191..52aacf4 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -13,6 +13,7 @@ import '../modelos/preset_ecualizador.dart'; import 'controlador_reconexion.dart'; import 'navegacion_auto.dart'; import 'servicio_audio_session.dart'; +import 'servicio_ecualizador.dart'; /// Estado de reproducción expuesto al UI. enum EstadoReproduccion { @@ -737,6 +738,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler if (parentMediaId == AudioService.browsableRootId) { return constructor.raiz(); } + if (parentMediaId == ConstructorArbolAuto.idEcualizador) { + return constructor.presetsEq(PresetEcualizador.presets); + } final fuente = _fuenteNavegacionGlobal; if (fuente == null) return const []; if (parentMediaId == ConstructorArbolAuto.idFavoritos) { @@ -779,9 +783,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler String mediaId, [ Map? extras, ]) async { - final fuente = _fuenteNavegacionGlobal; - if (fuente == null) return; try { + // EQ preset selection (Design ADR-3, Spec "EQ Preset Selection Applies + // Without Disturbing Playback"): FIRST branch, unconditional `return`, + // so an `eq_preset:` id can never fall through to the playback routing + // below. `aplicarPresetPorMediaId`'s seams are EQ-only (persist + + // apply) — there is no playback parameter to inject here. + if (esPresetMediaId(mediaId)) { + final servicio = ServicioEcualizador(); + await aplicarPresetPorMediaId( + mediaId, + presets: PresetEcualizador.presets, + uuidActual: emisoraActual?.uuid, + clavesPorEmisora: () async => + (await servicio.cargar()).porEmisora.keys.toSet(), + persistirPrincipal: servicio.guardarPrincipal, + aplicar: aplicarPreset, + ); + return; + } + final fuente = _fuenteNavegacionGlobal; + if (fuente == null) return; await reproducirPorMediaId( mediaId, fuente: fuente, diff --git a/openspec/changes/android-auto-eq-presets/apply-progress.md b/openspec/changes/android-auto-eq-presets/apply-progress.md new file mode 100644 index 0000000..231429d --- /dev/null +++ b/openspec/changes/android-auto-eq-presets/apply-progress.md @@ -0,0 +1,93 @@ +# Apply Progress: android-auto-eq-presets + +**Mode**: Strict TDD +**Batch**: First and only batch — all 21 tasks completed in one pass. + +## Completed Tasks + +### Phase 1: Foundation — media-id scheme + root folder +- [x] 1.1 [RED] `esPresetMediaId` table test +- [x] 1.2 [GREEN] `_prefijoPresetEq` const + `esPresetMediaId` +- [x] 1.3 [RED] `raiz` test updated to `hasLength(4)` + last-position assertions +- [x] 1.4 [GREEN] `idEcualizador` const + `raiz()` appends `Ecualizador` folder last +- [x] 1.5 [RED] `itemPresetEq` test +- [x] 1.6 [GREEN] `itemPresetEq` +- [x] 1.7 [RED] `presetsEq` test +- [x] 1.8 [GREEN] `presetsEq` + +### Phase 2: Core Implementation — resolve, gate, orchestrate +- [x] 2.1 [RED] `resolverPresetEq` table test +- [x] 2.2 [GREEN] `resolverPresetEq` free function +- [x] 2.3 [RED] `debeAplicarPrincipalAhora` table test +- [x] 2.4 [GREEN] `debeAplicarPrincipalAhora` pure gate function +- [x] 2.5 [RED] `aplicarPresetPorMediaId` known-preset gate-true/gate-false test +- [x] 2.6 [RED] `aplicarPresetPorMediaId` unknown/stale-id no-op test +- [x] 2.7 [RED] Structural non-playback invariant test (signature has no playback seam) +- [x] 2.8 [GREEN] `aplicarPresetPorMediaId` orchestration + +### Phase 3: Integration / Wiring +- [x] 3.1 `getChildren` — `idEcualizador` branch returns `presetsEq(PresetEcualizador.presets)` +- [x] 3.2 `playFromMediaId` — `eq_preset:` branch as first statement, unconditional `return`, wrapped in method's outer try/catch +- [x] 3.3 Import check — `preset_ecualizador.dart` already imported; added new `servicio_ecualizador.dart` import (required, was not previously imported in this file) + +### Phase 4: Testing / Verification +- [x] 4.1 `flutter test test/servicios/navegacion_auto_test.dart` — 46/46 passing (independently re-run twice with default and compact reporters) +- [x] 4.2 Grepped `test/` tree for other `raiz()`/root-count assertions — only `navegacion_auto_test.dart` matches, confirmed +- [x] 4.3 [DEVIATION] `flutter build`/`run`/`analyze`/`gen-l10n` NOT run (hangs in this environment, same precedent as prior change). Manual static review performed instead. + +### Phase 5: Cleanup +- [x] 5.1 [REFACTOR] Doc comments added referencing ADR-1/2/3/5 on all new public/free-function members; suite confirmed green after +- [x] 5.2 Active-preset indication — confirmed out of scope per ADR-6, no code added (tracking-only task) + +## Files Changed + +| File | Action | What Was Done | +|------|--------|----------------| +| `lib/servicios/navegacion_auto.dart` | Modified | Added `_prefijoPresetEq` const, `esPresetMediaId`, `ConstructorArbolAuto.idEcualizador`, `raiz()` now returns 4 folders (Ecualizador last), `itemPresetEq`, `presetsEq`, free functions `resolverPresetEq`, `debeAplicarPrincipalAhora`, `aplicarPresetPorMediaId` | +| `lib/servicios/servicio_audio.dart` | Modified | `getChildren` gains `idEcualizador` branch (before `fuente` guard); `playFromMediaId` restructured — single outer try/catch, `eq_preset:` branch first with unconditional return; added `servicio_ecualizador.dart` import | +| `test/servicios/navegacion_auto_test.dart` | Modified | Updated `raiz` test to `hasLength(4)`; added groups for `esPresetMediaId`, `itemPresetEq`, `presetsEq`, `resolverPresetEq`, `debeAplicarPrincipalAhora`, `aplicarPresetPorMediaId` (4 tests including structural non-playback invariant) | + +## TDD Cycle Evidence + +| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR | +|------|-----------|-------|------------|-----|-------|-------------|----------| +| 1.1/1.2 | `navegacion_auto_test.dart` | Unit | ✅ 34/34 (pre-existing baseline) | ✅ Written | ✅ Passed | ✅ 5 cases (table) | ➖ None needed | +| 1.3/1.4 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ➖ Single (fixed 4-folder shape) | ➖ None needed | +| 1.5/1.6 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ➖ Single | ➖ None needed | +| 1.7/1.8 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ✅ 6 presets iterated | ➖ None needed | +| 2.1/2.2 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ✅ 3 negative cases + 1 positive | ➖ None needed | +| 2.3/2.4 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ✅ 3 cases (null/no-override/override) | ➖ None needed | +| 2.5-2.8 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ✅ 4 tests (gate-true, gate-false, unknown-id, structural) | ✅ Clean (extracted gate + resolve as separate pure fns) | +| 3.1-3.3 | N/A (dispatch layer, zero coverage precedent per design baseline) | N/A | N/A (new branch in untested method) | N/A | N/A — manual review only, standard workflow per design's documented precedent | N/A | ➖ None needed | +| 5.1 | `navegacion_auto_test.dart` | Unit | ✅ 46/46 (post-implementation) | N/A (doc-only) | ✅ Suite still green | N/A | ✅ Doc comments added | + +Note on Phase 3: `servicio_audio.dart`'s `getChildren`/`playFromMediaId` dispatch layer has zero existing test coverage (confirmed in tasks.md baseline — no `test/servicios/servicio_audio_test.dart` exists). This is a pre-existing condition, not something introduced here; the design explicitly names the pure `navegacion_auto.dart` functions as the load-bearing tested layer (ADR-3), with the handler wiring as thin, structurally-safe delegation. No RED/GREEN test cycle applies to Phase 3 tasks — they are direct wiring edits verified by (a) the full Phase 1-2 pure-function suite staying green, and (b) manual static review per task 4.3. + +### Test Summary +- **Total tests written**: 15 new test cases (1 esPresetMediaId table [5 sub-cases], 1 itemPresetEq, 1 presetsEq, 2 resolverPresetEq, 3 debeAplicarPrincipalAhora, 4 aplicarPresetPorMediaId, plus the updated raiz test) +- **Total tests passing**: 46/46 in `test/servicios/navegacion_auto_test.dart` (34 pre-existing baseline + 12 new test blocks, some containing multiple sub-assertions/table cases) — independently re-confirmed via two separate `flutter test` invocations (default and `--reporter=compact`) +- **Layers used**: Unit (46), Integration (0), E2E (0) +- **Approval tests** (refactoring): None — no refactoring tasks; Phase 5.1 was pure doc-comment addition, verified via the full suite staying green +- **Pure functions created**: 4 (`esPresetMediaId`, `resolverPresetEq`, `debeAplicarPrincipalAhora`, plus `itemPresetEq`/`presetsEq` as pure builders) — `aplicarPresetPorMediaId` is an orchestrator with injected seams, not fully pure, by design (ADR-3) + +## Deviations from Design + +1. **`servicio_ecualizador.dart` import added to `servicio_audio.dart`** — not explicitly listed in task 3.3 (which only mentions verifying the `preset_ecualizador.dart` import), but required to instantiate `ServicioEcualizador()` per the design's data-flow pseudocode. This is a direct, unavoidable consequence of implementing 3.2 as specified — not a deviation from the design's intent, just an omission in the task's explicit file-diff enumeration. +2. **`playFromMediaId` restructured to a single outer try/catch** (was: `fuente`-null guard outside try/catch, `reproducirPorMediaId` call inside try/catch). This was required because the design mandates the `eq_preset:` branch run BEFORE the `fuente`-null guard while still being covered by exception safety (Spec "Unknown or stale preset id": no unhandled exception may propagate). Functionally equivalent for the pre-existing `emisora:`/`grupo:` path — behavior for those ids is unchanged (the `fuente == null` check remains, just now inside the try block, and its `return` behavior on null is unaffected). + +No other deviations — implementation matches design (ADR-1 through ADR-6 all satisfied as specified). + +## Issues Found + +None. + +## Workload / PR Boundary + +- Mode: single PR (no chaining needed — actual diff came in at 339 insertions + 7 deletions = 346 changed lines, within the 300-420 forecast and under the 400-line budget) +- Current work unit: N/A — single delivery, all tasks in one pass +- Boundary: Starts from a clean baseline (34/34 pre-existing tests green) and ends with the full `Ecualizador` folder + preset-selection feature wired end-to-end, 46/46 tests green +- Estimated review budget impact: Medium-low — under the 400-line reviewer budget; changes are additive (new branches alongside existing `emisora:`/`grupo:` dispatch, no restructuring of unrelated logic) + +## Status + +21/21 tasks complete. Ready for sdd-verify. diff --git a/openspec/changes/android-auto-eq-presets/design.md b/openspec/changes/android-auto-eq-presets/design.md new file mode 100644 index 0000000..fcf3f2b --- /dev/null +++ b/openspec/changes/android-auto-eq-presets/design.md @@ -0,0 +1,279 @@ +# Design: Android Auto EQ preset selection + +## Context + +`android-auto-eq-presets` adds a browsable `Ecualizador` folder to the existing +Android Auto media tree so a driver can apply one of the 6 fixed +`PresetEcualizador.presets` from the car, without touching the phone and without +interrupting the current station. This design was written against the CURRENT +post-favorite-groups codebase (main, after `f368bcc`/`066fedb`), reading the +live files — not an assumed older layout. + +Two facts from the code drive every decision below: + +- **`EstadoEcualizador` is a plain `ChangeNotifier`** (`lib/estado/estado_ecualizador.dart:28`) + with NO `BuildContext`/widget-tree dependency — but it is created lazily by a + `ChangeNotifierProvider.create:` and **may never build on a headless Android + Auto bind**, exactly like `EstadoRadio` for browse. So the car path MUST NOT + reach into it. +- **The handler already owns a headless-safe EQ seam.** + `PluriWaveAudioHandler.aplicarPreset` (`lib/servicios/servicio_audio.dart:580`) + mutates only `_presetActual` and the native `_eq`; it NEVER touches `mediaItem` + or `playbackState`. And `ServicioEcualizador.guardarPrincipal` + (`lib/servicios/servicio_ecualizador.dart:129`) writes the same + SharedPreferences key `eq_preset_principal_v1` (line 39) that the phone's + `EstadoEcualizador.cargarPersistido()` reads. This is the working headless + bridge Android Auto already uses for play/pause — we reuse it, we do not invent + a parallel one. + +## Goals / Non-Goals + +**Goals** +- Browsable `Ecualizador` root folder listing the 6 presets by name. +- `eq_preset:` media-id scheme, intercepted in `playFromMediaId` BEFORE + the `emisora:`/`grupo:` routing, applying + persisting the preset as principal. +- Structural, testable guarantee that a preset tap NEVER disturbs current + playback or now-playing metadata. +- Headless-safe write path with eventual phone/car parity via SharedPreferences. + +**Non-Goals** +- Live phone-UI refresh of the EQ screen while an Auto session mutates EQ + (see ADR-4 — eventual parity only, by design). +- Band-level / per-station / per-device / matrix EQ from the car. +- A transport-button EQ action. +- An accurate "currently-selected" checkmark on preset rows (ADR-6, scoped out). + +## Architecture Approach + +Mirror the proven `emisora:`/`grupo:` interception pattern already in this +capability: **pure, injectable free functions in `navegacion_auto.dart`** do all +the logic; the untested `PluriWaveAudioHandler` dispatch layer stays a thin +3-line delegation. This is the same shape as the existing +`reproducirPorMediaId` free function (`navegacion_auto.dart:267`) — a driver +tap routes through `playFromMediaId`, which delegates to a fully unit-testable +function with injected seams and no platform dependency. + +The tree-building additions live in the existing pure `ConstructorArbolAuto` +class next to `raiz()`/`itemEmisora`/`itemGrupo`. + +### Component map + +| Component | Location | Kind | Responsibility | +|-----------|----------|------|----------------| +| `_prefijoPresetEq = 'eq_preset:'` | `navegacion_auto.dart` (top-level const, next to `_prefijoEmisora`) | new const | Single source of the id prefix, shared by builder + free functions | +| `ConstructorArbolAuto.idEcualizador = 'ecualizador'` | `navegacion_auto.dart` | new const | Root folder id for the EQ folder | +| `ConstructorArbolAuto.raiz()` | `navegacion_auto.dart:152` | modified | Append the `Ecualizador` folder as the LAST root entry (ADR-2) | +| `ConstructorArbolAuto.itemPresetEq(PresetEcualizador)` | `navegacion_auto.dart` | new | Map a preset → `playable:true` leaf `MediaItem` id `eq_preset:` | +| `ConstructorArbolAuto.presetsEq(List)` | `navegacion_auto.dart` | new | The 6 preset leaf items for the Ecualizador folder | +| `esPresetMediaId(String)` | `navegacion_auto.dart` | new free fn | Pure routing predicate: id starts with `eq_preset:` | +| `resolverPresetEq(String id, List)` | `navegacion_auto.dart` | new free fn | Exact-name resolve; `null` for unknown/empty (no throw) | +| `debeAplicarPrincipalAhora({uuidActual, clavesPorEmisora})` | `navegacion_auto.dart` | new pure fn | Mirrors `cambiarPresetPrincipal` apply gate (ADR-5) | +| `aplicarPresetPorMediaId(...)` | `navegacion_auto.dart` | new free fn | Orchestrates resolve → persist principal → conditional native apply, via injected seams ONLY (ADR-3/ADR-4) | +| `PluriWaveAudioHandler.getChildren` | `servicio_audio.dart:731` | modified | New branch: `parentMediaId == idEcualizador` → `constructor.presetsEq(...)` | +| `PluriWaveAudioHandler.playFromMediaId` | `servicio_audio.dart:778` | modified | New `eq_preset:` branch BEFORE the fuente-null guard and playback routing; `return`s without falling through | +| `test/servicios/navegacion_auto_test.dart:225` | test | modified | `raiz` length assertion 3 → 4 (breaks otherwise) | + +### Data flow — a preset tap + +``` +Car head unit: driver taps "Rock" in the Ecualizador folder + -> MediaBrowserService delivers eq_preset:Rock + -> PluriWaveAudioHandler.playFromMediaId("eq_preset:Rock") + if esPresetMediaId(id): // NEW, first branch + servicio = ServicioEcualizador() // headless-safe (SP) + await aplicarPresetPorMediaId( + "eq_preset:Rock", + presets: PresetEcualizador.presets, + uuidActual: emisoraActual?.uuid, + clavesPorEmisora: () async => (await servicio.cargar()).porEmisora.keys.toSet(), + persistirPrincipal: servicio.guardarPrincipal, // -> SP key eq_preset_principal_v1 + aplicar: aplicarPreset, // -> native _eq, NOT playback + ) + return; // NEVER reaches playback + -> aplicarPresetPorMediaId: + preset = resolverPresetEq(id, presets) // "Rock" | null + if preset == null: return // unknown/stale -> no-op + await persistirPrincipal(preset) // durable parity + if debeAplicarPrincipalAhora(uuidActual, keys): // ADR-5 gate + await aplicar(preset) // live native gains +``` + +Phone side (later): `EstadoEcualizador.cargarPersistido()` reads +`eq_preset_principal_v1` → `_presetPrincipal = Rock`. Native sound was already +Rock live. Parity achieved (eventual — ADR-4). + +### Integration points + +- **Root tree shape changes 3 → 4 folders.** The base spec scenario "Car + requests the root … returns three folder `MediaItem`s" becomes four + (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador). This is a spec + delta for `sdd-spec` to record, and `navegacion_auto_test.dart:225` must move + from `hasLength(3)` to `hasLength(4)`. +- **`getChildren` gains one branch** for `idEcualizador`; it needs NO data + source (`_fuenteNavegacionGlobal`) because the preset list is a compile-time + constant, so it is placed before the `fuente == null` guard's dependents. +- **`playFromMediaId` gains one branch** placed FIRST (before the `fuente == + null` guard) so EQ works even if the browse source was never registered. +- **`ServicioEcualizador` instantiation** inside the handler branch self-resolves + SharedPreferences via `getInstance()` (its `_prefs` fallback), matching how + `FuenteEmisorasAutoLocal` defaults its own `ServicioFavoritos()`. + +## Decisions (ADR-style) + +### ADR-1 — Media-id scheme `eq_preset:` + +**Decision.** New top-level prefix `eq_preset:`; the leaf id is +`eq_preset:` + the preset's exact `nombre` (e.g. `eq_preset:Bass Boost`). +Resolution is exact-name match against `PresetEcualizador.presets`. + +**Why.** Collision-free against every existing id shape: `emisora:` (leaf), +`grupo:` (folder), and the bare folder constants `favoritos` / `todas` / +`mis_emisoras` / the new `ecualizador`. Preset names are the natural stable key +(there are only 6 fixed presets, names are unique and constant). Spaces in +`Bass Boost` are inert in a `MediaItem.id` string. + +**Rejected.** Index-based ids (`eq_preset:3`) — brittle against any future +reordering of the fixed list and unreadable in logs; name is self-describing and +matches how the phone identifies a preset. + +### ADR-2 — `Ecualizador` folder placed LAST at the root + +**Decision.** `raiz()` returns `[Favoritos, Todas las emisoras, Mis emisoras, +Ecualizador]` — EQ last. + +**Why.** Content-browsing folders are the primary car task and stay first; +`Ecualizador` is a settings-like tool, not content. Trailing placement matches +head-unit conventions (tools after content) and minimizes the chance a driver +lands in it by accident while reaching for stations. + +**Rejected.** First/second position — would push the driver's primary target +(favorites/stations) down and read as if EQ were content. + +### ADR-3 — Non-playback invariant enforced structurally, not by discipline + +**Decision.** The EQ orchestration lives in the free function +`aplicarPresetPorMediaId`, whose signature exposes ONLY two side-effect seams: +`persistirPrincipal` and `aplicar`. It has NO parameter for `playMediaItem`, +`mediaItem`, or `playbackState`, so there is literally no code path from a preset +tap to playback. In `playFromMediaId` the `eq_preset:` branch is FIRST and +`return`s, so it can never fall through to `reproducirPorMediaId`. + +**What is touched by a preset tap:** `_presetActual` (via `aplicarPreset`), the +native `AndroidEqualizer` band gains, and the SharedPreferences key +`eq_preset_principal_v1`. **What is NOT touched:** `mediaItem` (never `.add`-ed), +`playbackState` (never `copyWith`-ed), `_player`, `emisoraActual`, +`_intencionReproducir`, the reconnect machine. A station that is playing keeps +playing; a stopped handler stays stopped. + +**How a test asserts "playback undisturbed":** +1. *Primary (structural, pure):* a unit test drives `aplicarPresetPorMediaId` + with spy seams and asserts `aplicar` and `persistirPrincipal` each fire once + with the resolved preset — and that the function cannot reference any playback + seam because none is injected. The invariant is guaranteed by construction, + not by a runtime observation that could regress. +2. *Routing:* `esPresetMediaId('eq_preset:Rock')` is `true` and + `esPresetMediaId('emisora:uuid')` is `false`, proving an `eq_preset:` id is + diverted before ever reaching `reproducirPorMediaId`. +3. *Handler-level (opportunistic):* if a fake-backed handler test is added, snapshot + `handler.mediaItem.value` and subscribe to `handler.mediaItem` before the tap, + then assert the value is identical and NO new event was emitted after + `playFromMediaId('eq_preset:Rock')`. This is a belt-and-suspenders check on top + of the structural guarantee; the dispatch layer's zero-coverage reality means + (1) is the load-bearing assertion. + +**Why.** The proposal's top risk is a preset tap looking like "now playing a new +track." Enforcing the invariant in the type/seam shape (not in a comment or a +reviewer's vigilance) is the strongest possible mitigation in a dispatch layer +with no execution coverage. + +### ADR-4 — Headless persistence seam: handler + service, NOT `EstadoEcualizador` + +**Decision.** The car write path is `ServicioEcualizador.guardarPrincipal` +(SharedPreferences `eq_preset_principal_v1`) + `PluriWaveAudioHandler.aplicarPreset` +(native gains). It does NOT call `EstadoEcualizador.cambiarPresetPrincipal`. +Phone/car parity is **eventual**: the phone's `EstadoEcualizador` reflects the +car's choice on its next `cargarPersistido()` (init / reload). The native sound +and the handler's live `_presetActual` update immediately. + +**Why.** Confirmed by reading the class: `EstadoEcualizador` is a plain +`ChangeNotifier` (no `BuildContext`), BUT it is provider-created and may never +exist on a headless Auto bind — the same reason this capability already built +`FuenteEmisorasAutoLocal` instead of reaching into `EstadoRadio` for browse. +Reaching a possibly-null widget-tree object from the headless handler would be +fragile. Both surfaces already converge on TWO shared authorities the handler +owns headlessly: the SharedPreferences key (durable) and the handler's +`aplicarPreset`/`_presetActual` (live native). The phone writes to the exact +same key on `cambiarPresetPrincipal` → `guardarPrincipal`, so phone→car parity is +already there symmetrically. + +**Live phone-UI refresh is explicitly OUT of scope.** Pushing a car change into a +live `EstadoEcualizador._presetPrincipal` would require a NEW handler→state +registration bridge (there is no existing EQ listener to reuse — the only +existing bridges are `registrarHandler` and `registrarFuenteNavegacion`, neither +of which carries EQ state). The dominant real scenario (phone headless or not on +the EQ screen while the driver uses the head unit) makes live refresh low value +against real added surface + a widget-tree wiring change. Eventual consistency on +next foreground load is the accepted behavior for this iteration. + +**Rejected.** (a) Calling `EstadoEcualizador` directly — may not exist headless. +(b) Adding a live sync bridge now — new surface, widget-tree wiring, marginal +value while driving; revisit only if a concrete need appears. + +### ADR-5 — Mirror `cambiarPresetPrincipal` per-station apply semantics + +**Decision.** `aplicarPresetPorMediaId` always persists the principal, but only +applies it to the native EQ live when `debeAplicarPrincipalAhora` is true: +`uuidActual == null` (no station playing) OR the current station has no +per-station preset override (`!clavesPorEmisora.contains(uuidActual)`). This is +the exact gate `EstadoEcualizador.cambiarPresetPrincipal` uses +(`estado_ecualizador.dart:302-307`). + +**Why.** The car is just another button for the SAME "change the global preset" +action; it must obey the same rules as the phone so the two surfaces never +diverge. If the driver's current station has a deliberately-set per-station +preset, changing the GLOBAL preset from the car should not silently blow away +that override's live sound — identical to phone behavior. The gate decision is a +PURE function (`uuid + key set → bool`), fully unit-testable, so the parity logic +is covered even though the handler dispatch is not. The one extra +`servicio.cargar()` read per tap is negligible for an occasional manual action. + +**Rejected.** Unconditional live apply — thinner, but diverges from the phone in +the per-station-override case and would momentarily override a preset the user +deliberately pinned (it reasserts on next station switch anyway, so the divergence +is pure inconsistency with no upside). + +### ADR-6 — Active-preset marker scoped OUT this iteration + +**Decision.** Preset rows carry the plain preset name, no "selected" marker. + +**Why.** The legacy `MediaBrowserService` model has no per-row selected +affordance, so the only option is a title-text prefix (e.g. `● Rock`). But the +browse tree is NOT re-queried after an `eq_preset:` tap (this path deliberately +does no `notifyChildrenChanged` — it must not touch playback/tree state), so a +prefix marker would go stale the instant the driver picks a different preset and +would actively lie about the current state. A marker that lies is worse than no +marker. Ship clean rows; revisit only if a reliable tree-refresh trigger is added. + +## Risks / Open Questions + +- **Root tree 3 → 4 folders is a base-spec delta.** `sdd-spec` must update the + "Car requests the root" scenario, and `navegacion_auto_test.dart:225` + (`hasLength(3)`) must become `hasLength(4)` in the same change or the suite + breaks. Flagged, low risk (mechanical). +- **Eventual (not live) phone parity** (ADR-4) — accepted limitation. Risk: a + user with the EQ screen open on the phone AND browsing Auto EQ simultaneously + sees a stale principal until reload. Judged low-value corner; documented, not + fixed. +- **`servicio.cargar()` on each tap** triggers `migrarClavesPlaceholder()` — a + guarded one-time no-op after first run; negligible cost, no correctness risk. +- **Shared-file merge drift** with the just-shipped favorite-groups work is the + main integration hazard; this design adds branches ALONGSIDE the existing + `grupo:`/`emisora:` dispatch in the two shared files rather than restructuring + them, keeping the diff additive and the rollback a clean revert. + +## Migration / Rollback + +Additive only — no schema/migration. Reverting the feature commits removes the +`Ecualizador` folder, the `eq_preset:` branch, and the tree helpers, restoring the +exact prior tree; the SharedPreferences key `eq_preset_principal_v1` is the +pre-existing phone key and is untouched structurally. diff --git a/openspec/changes/android-auto-eq-presets/proposal.md b/openspec/changes/android-auto-eq-presets/proposal.md new file mode 100644 index 0000000..37d1747 --- /dev/null +++ b/openspec/changes/android-auto-eq-presets/proposal.md @@ -0,0 +1,67 @@ +# Proposal: Android Auto EQ preset selection + +## Intent + +Drivers cannot change the audio equalizer while on Android Auto — EQ selection is phone-UI only today. Expose the 6 fixed presets as a browsable folder in the existing Auto media tree so a preset can be applied from the car, without reaching for the phone and without interrupting the current station. + +## Scope + +### In Scope +- New top-level browsable `Ecualizador` folder in `ConstructorArbolAuto.raiz()`, listing the 6 fixed `PresetEcualizador.presets` by name. +- New media-id scheme `eq_preset:`, intercepted in `playFromMediaId` BEFORE the `emisora:`/`grupo:` routing. +- Tapping a preset applies it through the existing headless-safe EQ seam and persists it as the principal preset (phone/car parity), with NO call to `playMediaItem` and NO `mediaItem` mutation. +- Strict-TDD tests: tree shape, `eq_preset:` id resolution, and the "preset tap does not disturb playback / now-playing" invariant. +- Optional (lower priority): mark the active preset in its row title (see Risks — no native selection UI exists in this model). + +### Out of Scope +- Custom band-level EQ editing or arbitrary EQ from the car (media-browser capability ceiling; stays phone-only). +- Per-station / per-device / matrix EQ writes from the car (global principal only). +- Custom transport-button EQ actions (button real-estate fits ~1-2 slots, not 6 named presets). +- Favorite-groups browse work (separate, already-shipped `android-auto-favorite-groups`). + +## Capabilities + +### New Capabilities +- None + +### Modified Capabilities +- `android-auto-media`: the browse tree gains an `Ecualizador` root folder plus an `eq_preset:` selection action that applies an EQ preset without starting or altering playback. + +## Approach + +Mirror the proven `emisora:`/`grupo:` interception pattern already in `navegacion_auto.dart` + `servicio_audio.dart`. Preset rows are `playable: true` (the only tappable non-folder item type in the legacy `MediaBrowserService` model) and therefore route through `playFromMediaId`; a new `eq_preset:` branch resolves the preset by name and calls the EQ apply seam instead of the audio player. Reuse the phone's global-preset semantics (apply via `PluriWaveAudioHandler.aplicarPreset` + persist via `ServicioEcualizador.guardarPrincipal`, matching `cambiarPresetPrincipal`) so phone and car stay in sync. The write path MUST be headless-safe (handler + service, NOT the widget-tree `EstadoEcualizador`, which may never build on an Auto bind). + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `lib/servicios/navegacion_auto.dart` | Modified | Add `Ecualizador` root folder, `eq_preset:` id scheme, preset-row builder, resolver + selection routing helper | +| `lib/servicios/servicio_audio.dart` | Modified | Branch `playFromMediaId` on `eq_preset:` to the apply/persist seam before `emisora:`/`grupo:` | +| `lib/modelos/preset_ecualizador.dart` | Read-only | Source of the 6 fixed presets (name is the id key) | +| `lib/servicios/servicio_ecualizador.dart` | Read-only reuse | `guardarPrincipal` persistence seam (headless-safe) | +| `openspec/specs/android-auto-media/spec.md` | Delta only | Modified-capability delta in this change folder; base spec untouched | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Preset tap looks like "now playing a new track" (playable item) | Med | Never call `playMediaItem`, never mutate `mediaItem`; dedicated `eq_preset:` branch + invariant test | +| Shared-file merge drift with just-shipped favorite-groups | Med | Read CURRENT post-`f368bcc`/`066fedb` state; add branch alongside existing `grupo:`/`emisora:` dispatch | +| No native "selected" UI for browsable rows | High | Active-preset marker only via title text (prefix); ship as optional, be explicit it is not a real checkmark | +| Persist path unavailable on headless Auto bind | Med | Route through service + handler (SharedPreferences), not `EstadoEcualizador` | + +## Rollback Plan + +Additive only — revert the feature commits. No schema/migration changes: removing the `Ecualizador` folder and the `eq_preset:` branch restores the exact prior tree (favorite-groups behavior unaffected). + +## Dependencies + +- Sequenced after `android-auto-favorite-groups` (now on main) to avoid conflicts in the two shared files. + +## Success Criteria + +- [ ] `Ecualizador` folder appears at the Auto root with all 6 presets by name. +- [ ] Tapping a preset applies it (native EQ gains change) and persists as principal. +- [ ] Current station playback / now-playing metadata is unchanged by a preset tap. +- [ ] Selecting a preset in the car is reflected in the phone EQ UI and vice versa. +- [ ] Unknown/stale `eq_preset:` id is a no-op (no throw). diff --git a/openspec/changes/android-auto-eq-presets/specs/android-auto-media/spec.md b/openspec/changes/android-auto-eq-presets/specs/android-auto-media/spec.md new file mode 100644 index 0000000..a63784e --- /dev/null +++ b/openspec/changes/android-auto-eq-presets/specs/android-auto-media/spec.md @@ -0,0 +1,115 @@ +# Delta for android-auto-media + +## MODIFIED Requirements + +### Requirement: Browsable Media Tree + +`getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador) containing playable items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders; `Todas las emisoras` and `Mis emisoras` remain flat. The `Ecualizador` folder is flat, non-playable, and contains only the 6 fixed EQ preset items (see "EQ Preset Browsable Folder"). +(Previously: root contained exactly 3 folders — Favoritos, Todas las emisoras, Mis emisoras — with no EQ folder.) + +#### Scenario: Car requests the root + +- GIVEN the car head unit connects and requests the root (`AudioService.browsableRootId`) +- WHEN `getChildren` is called with the root id +- THEN it returns four folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador), each with `playable: false` + +#### Scenario: Car requests a folder with no stations + +- GIVEN the user has zero favorite stations +- WHEN `getChildren` is called with the Favoritos folder id +- THEN it returns an empty list, not an error + +#### Scenario: Browse requested before app state is loaded + +- GIVEN the audio handler starts cold and station/favorites Provider state has not finished loading +- WHEN `getChildren` is called (root or any folder) +- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service + +#### Scenario: Station has known codec and bitrate + +- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known +- WHEN it is mapped to a playable `MediaItem` +- THEN `displaySubtitle` SHALL contain a human-readable quality hint (e.g. "128 kbps · MP3") + +#### Scenario: Station has unknown codec or bitrate + +- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` is null/unknown +- WHEN it is mapped to a playable `MediaItem` +- THEN `displaySubtitle` SHALL omit the quality hint gracefully, never rendering literal placeholder text + +#### Scenario: Ungrouped station appears exactly as before (regression guard) + +- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId` +- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed +- THEN that station appears as a playable `emisora:` item in the same folder(s), position, title, art, and subtitle as before this change +- AND its presence and shape are unaffected by the new `Ecualizador` folder + +## ADDED Requirements + +### Requirement: EQ Preset Browsable Folder + +The Android Auto browse tree MUST expose the 6 fixed EQ presets (`PresetEcualizador.presets`) as playable items inside the `Ecualizador` root folder, each using a distinct `eq_preset:` media-id scheme, separate from `emisora:` and `grupo:`. + +#### Scenario: Car requests the Ecualizador folder + +- GIVEN the `Ecualizador` folder id was returned under the root +- WHEN `getChildren` is called with the `Ecualizador` folder id +- THEN it returns exactly 6 playable `MediaItem`s, one per `PresetEcualizador.presets` entry, titled with the preset's name +- AND each item's id is `eq_preset:`, where `` is that preset's unique name + +### Requirement: EQ Preset Selection Applies Without Disturbing Playback + +Selecting an `eq_preset:` item MUST apply that preset immediately through the headless-safe EQ seam (e.g. `PluriWaveAudioHandler.aplicarPreset`), via a branch in `playFromMediaId` intercepted BEFORE the `emisora:`/`grupo:` routing. It MUST NOT call `playMediaItem`, MUST NOT mutate the now-playing `mediaItem`, and MUST NOT start, stop, restart, or otherwise alter current playback or playback position. + +#### Scenario: User selects a preset while a station is playing + +- GIVEN a station is currently playing and projected to the car +- WHEN the user taps an `eq_preset:` item, resolved via `playFromMediaId` +- THEN the matching preset is applied via the headless-safe apply seam +- AND the currently playing station continues uninterrupted, with no change to playback position or now-playing metadata + +#### Scenario: User selects a preset while nothing is playing + +- GIVEN no station is currently playing +- WHEN the user taps an `eq_preset:` item +- THEN the matching preset is applied via the headless-safe apply seam +- AND no playback starts as a result of the selection + +#### Scenario: Unknown or stale preset id + +- GIVEN `playFromMediaId` receives an `eq_preset:` id whose `` matches no entry in the current `PresetEcualizador.presets` +- WHEN resolution fails to find a matching preset +- THEN the selection is a no-op: no preset is applied, no playback state changes, and no unhandled exception propagates from the handler + +### Requirement: EQ Preset Persistence and Phone/Car Parity + +An EQ preset applied from the car MUST persist as the principal preset, with the same observable outcome as the phone's existing `ServicioEcualizador.guardarPrincipal`/`cambiarPresetPrincipal` path, and MUST be reflected on the phone. A principal preset changed on the phone MUST be observable from the car. The exact code path used to reach the headless-safe persistence seam from an Android Auto bind is deferred to `sdd-design`. + +#### Scenario: Preset applied from the car persists for the phone + +- GIVEN the user selects `eq_preset:` from the car head unit +- WHEN the selection is processed +- THEN the preset is persisted as the principal preset +- AND opening the phone's EQ UI afterward shows that preset as the active/principal preset + +#### Scenario: Preset applied from the phone is reflected for the car + +- GIVEN the user changes the principal preset from the phone UI +- WHEN the car head unit subsequently observes EQ-related state through the browse/selection surface +- THEN the car-observable state reflects the phone's most recently applied principal preset + +### Requirement: Active Preset Indication (Optional) + +The system MAY indicate the active/principal preset within the `Ecualizador` folder's rows. Because the legacy `MediaBrowserService` browsable-item model has no native "selected item" affordance, any indication, if implemented, MUST be conveyed via a row `title` text convention (e.g. a marker prefix/suffix) rather than assuming a checkmark or selection icon exists. If a reliable title-text convention is too costly to maintain, this requirement MAY instead be satisfied by explicitly not implementing active-preset indication. + +#### Scenario: Active preset is indicated via title convention (if implemented) + +- GIVEN the currently applied principal preset is known when `Ecualizador`'s children are built +- WHEN the design's chosen title-text convention is applied +- THEN exactly one of the 6 preset rows' `title` carries the active-preset marker, uniquely identifying that preset among the 6 + +#### Scenario: Active preset indication is out of scope (if not implemented) + +- GIVEN the design opts not to implement an active-preset marker +- WHEN the `Ecualizador` folder's children are built +- THEN all 6 preset rows are returned with their plain preset names, with no partial or inconsistent marking applied diff --git a/openspec/changes/android-auto-eq-presets/tasks.md b/openspec/changes/android-auto-eq-presets/tasks.md new file mode 100644 index 0000000..ff31589 --- /dev/null +++ b/openspec/changes/android-auto-eq-presets/tasks.md @@ -0,0 +1,82 @@ +# Tasks: Android Auto EQ preset selection + +Strict TDD active for Dart layers. Behavioral task = RED (failing test) -> GREEN +(minimal impl) -> REFACTOR. `flutter build`/`flutter run`/`flutter analyze`/ +`flutter gen-l10n` MUST NOT be executed in this environment (hang) — marked +**[DEVIATION]**, same precedent as +`openspec/changes/archive/2026-07-19-android-auto-favorite-groups/tasks.md`. + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | 300-420 | +| 400-line budget risk | Medium | +| Chained PRs recommended | No | +| Suggested split | Single PR; fallback 2-way split if apply exceeds budget: PR 1 = Phase 1-2 (pure builders/functions + tests), PR 2 = Phase 3-5 (handler wiring + cleanup) | +| Delivery strategy | ask-on-risk (default, not overridden by caller) | +| Chain strategy | pending | + +Decision needed before apply: No +Chained PRs recommended: No +Chain strategy: pending +400-line budget risk: Medium + +### Suggested Work Units + +| Unit | Goal | Likely PR | Notes | +|------|------|-----------|-------| +| 1 | Media-id scheme + pure builders/functions (Phase 1-2) + tests | PR 1 | Fully unit-testable, no handler dependency | +| 2 | Handler wiring (Phase 3) + broken-assertion fix + verification (Phase 4-5) | PR 2 (fallback only) | Depends on Unit 1; only split out if diff runs over 400 | + +## Baseline (verified against live code, not spec/design prose) + +- `lib/servicios/navegacion_auto.dart:13` — `_prefijoEmisora = 'emisora:'` top-level const; no `eq_preset:` prefix exists yet. +- `lib/servicios/navegacion_auto.dart:119-256` `ConstructorArbolAuto` — `idFavoritos`/`idTodas`/`idMisEmisoras` (123-125), `_idsCarpetas` (127, do NOT add `idEcualizador` here — it needs its own dedicated branch, not the generic `hijos()` station path), `_prefijoGrupo` (132), `raiz()` (152-156, currently 3 folders), `_carpeta()` helper (158), `itemEmisora()` (181), `resolver()` (193), `esCarpetaGrupo()` (205), `itemGrupo()` (209). +- `lib/servicios/servicio_audio.dart:731-761` `getChildren` — root check, then `fuente == null` guard, then `idFavoritos`/`esCarpetaGrupo`/default branches. +- `lib/servicios/servicio_audio.dart:778-798` `playFromMediaId` — `fuente == null` guard first, then delegates to `reproducirPorMediaId`; **zero existing test coverage** for this dispatch layer (no `test/servicios/servicio_audio_test.dart` exists). +- `lib/servicios/servicio_audio.dart:183` `emisoraActual` — public field on `PluriWaveAudioHandler`, directly readable (no getter indirection needed for `uuidActual`). +- `lib/servicios/servicio_audio.dart:580-601` `aplicarPreset` — headless-safe seam, touches only `_presetActual`/native EQ, never `mediaItem`/`playbackState`. +- `lib/servicios/servicio_ecualizador.dart:129-132` `guardarPrincipal` — writes SP key `eq_preset_principal_v1` (line 39). +- `lib/estado/estado_ecualizador.dart:295-310` `cambiarPresetPrincipal` — confirmed gate: `uuid == null || !_presetsEmisoraMap.containsKey(uuid)` (302-304) is the exact logic `debeAplicarPrincipalAhora` must mirror. +- `lib/modelos/preset_ecualizador.dart:35` — `PresetEcualizador.presets` is exactly 6 fixed entries (Flat, Rock, Pop, Bass Boost, Jazz, Voz). +- `test/servicios/navegacion_auto_test.dart:220-239` — `hasLength(3)` at line 225 and the 3-id `equals()` set at 229-233 both need updating; no other test file references root-folder count. + +## Phase 1: Foundation — media-id scheme + root folder + +- [x] 1.1 [RED] `navegacion_auto_test.dart`: `esPresetMediaId` table — `'eq_preset:Rock'`→true, `'eq_preset:'`→true (prefix-only, resolves to null downstream), `'emisora:x'`/`'grupo:g1'`/`''`→false. *(Spec "EQ Preset Browsable Folder"; ADR-1)* +- [x] 1.2 [GREEN] `navegacion_auto.dart`: add `_prefijoPresetEq = 'eq_preset:'` top-level const (next to `_prefijoEmisora`, line 13); `esPresetMediaId(id) => id.startsWith(_prefijoPresetEq)`. +- [x] 1.3 [RED] `raiz` test (update existing `test/servicios/navegacion_auto_test.dart:220-239` group): `hasLength(3)` → `hasLength(4)`; id set gains `ConstructorArbolAuto.idEcualizador`; add explicit assertion that `raiz().last.id == ConstructorArbolAuto.idEcualizador` (ADR-2, last position) and `raiz().last.playable == false`. +- [x] 1.4 [GREEN] `navegacion_auto.dart`: add `idEcualizador = 'ecualizador'` const (123-125 block, NOT added to `_idsCarpetas`); append `_carpeta(idEcualizador, 'Ecualizador')` as the last entry in `raiz()` (line 152-156). +- [x] 1.5 [RED] `itemPresetEq` test — id `'eq_preset:'`, `playable: true`, `title == preset.nombre`. *(Spec "Car requests the Ecualizador folder")* +- [x] 1.6 [GREEN] `navegacion_auto.dart`: `itemPresetEq(preset) => MediaItem(id: '$_prefijoPresetEq${preset.nombre}', title: preset.nombre, playable: true, extras: _contentStyleGrid)`. +- [x] 1.7 [RED] `presetsEq` test — given `PresetEcualizador.presets`, returns exactly 6 items, one per preset, ids/titles match. *(Spec scenario "Car requests the Ecualizador folder")* +- [x] 1.8 [GREEN] `navegacion_auto.dart`: `presetsEq(presets) => presets.map(itemPresetEq).toList()`. + +## Phase 2: Core Implementation — resolve, gate, orchestrate + +- [x] 2.1 [RED] `resolverPresetEq` table — known name → matching preset; unknown name / empty-after-prefix / non-`eq_preset:` id → `null`, no throw. *(Spec "Unknown or stale preset id")* +- [x] 2.2 [GREEN] `navegacion_auto.dart`: `resolverPresetEq(id, presets)` — guard `esPresetMediaId`, strip prefix, empty→null, exact-name loop match→preset else null (mirrors `resolver()` shape, line 193). +- [x] 2.3 [RED] `debeAplicarPrincipalAhora` table — `uuidActual: null`→true; `uuidActual` not in `clavesPorEmisora`→true; `uuidActual` in `clavesPorEmisora`→false. *(ADR-5, mirrors `estado_ecualizador.dart:302-304`)* +- [x] 2.4 [GREEN] `navegacion_auto.dart`: pure `debeAplicarPrincipalAhora({uuidActual, clavesPorEmisora}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual)`. +- [x] 2.5 [RED] `aplicarPresetPorMediaId` — known preset: `persistirPrincipal` called once with resolved preset; `aplicar` called once when gate is true; `aplicar` NOT called when gate is false (per-station override case). *(ADR-3, ADR-5)* +- [x] 2.6 [RED] `aplicarPresetPorMediaId` — unknown/stale id: neither `persistirPrincipal` nor `aplicar` is invoked, function returns without throwing. *(Spec "Unknown or stale preset id")* +- [x] 2.7 [RED] Structural invariant test — assert `aplicarPresetPorMediaId`'s signature exposes ONLY `persistirPrincipal`/`aplicar`/`uuidActual`/`clavesPorEmisora` seams (no `playMediaItem`/`mediaItem`/`playbackState` parameter exists to inject); document this as the load-bearing non-playback proof per ADR-3. +- [x] 2.8 [GREEN] `navegacion_auto.dart`: implement `aplicarPresetPorMediaId(id, {required presets, required uuidActual, required Future> Function() clavesPorEmisora, required Future Function(PresetEcualizador) persistirPrincipal, required Future Function(PresetEcualizador) aplicar})` — resolve via `resolverPresetEq`; `null`→return; else `await persistirPrincipal(preset)`; `if (debeAplicarPrincipalAhora(uuidActual: uuidActual, clavesPorEmisora: await clavesPorEmisora())) await aplicar(preset);`. Satisfies 2.5-2.7. + +## Phase 3: Integration / Wiring + +- [x] 3.1 `servicio_audio.dart:731-761` `getChildren`: insert `if (parentMediaId == ConstructorArbolAuto.idEcualizador) return constructor.presetsEq(PresetEcualizador.presets);` immediately after the root-id check and BEFORE `final fuente = _fuenteNavegacionGlobal;` (no data source needed — compile-time constant list, per design's Integration Points). +- [x] 3.2 `servicio_audio.dart:778-798` `playFromMediaId`: insert an `esPresetMediaId(mediaId)` branch as the FIRST statement in the method body, BEFORE `final fuente = _fuenteNavegacionGlobal; if (fuente == null) return;`. Branch body: `final servicio = ServicioEcualizador(); await aplicarPresetPorMediaId(mediaId, presets: PresetEcualizador.presets, uuidActual: emisoraActual?.uuid, clavesPorEmisora: () async => (await servicio.cargar()).porEmisora.keys.toSet(), persistirPrincipal: servicio.guardarPrincipal, aplicar: aplicarPreset); return;` — wrapped in the method's existing outer try/catch so an unexpected failure still never propagates (Spec "Unknown or stale preset id"). *(ADR-3: FIRST branch + unconditional `return` means it can never fall through to `reproducirPorMediaId`.)* +- [x] 3.3 Add `import '../modelos/preset_ecualizador.dart';` to `servicio_audio.dart` if not already present (check before adding — `aplicarPreset`'s existing signature at line 580 already takes `PresetEcualizador`, so the import likely already exists; verify, do not duplicate). + +## Phase 4: Testing / Verification + +- [x] 4.1 Run `flutter test test/servicios/navegacion_auto_test.dart` — full green, including the updated `hasLength(4)` root test. (46/46 passing, independently re-run twice.) +- [x] 4.2 Grep the full `test/` tree for any other `raiz()`/root-folder-count assertions outside `navegacion_auto_test.dart` (baseline found none as of this writing — confirm still true before merge). Confirmed: only `navegacion_auto_test.dart` matches. +- [x] 4.3 [DEVIATION] `flutter build`/`flutter run`/`flutter analyze`/`flutter gen-l10n` — DO NOT RUN in this environment (hangs). Manual static review instead: no unused symbols, `eq_preset:` prefix non-colliding with `emisora:`/`grupo:`/bare folder ids by inspection, `playFromMediaId`'s new branch returns unconditionally with no fallthrough. Real run recommended before merge/CI. + +## Phase 5: Cleanup + +- [x] 5.1 [REFACTOR] `navegacion_auto.dart`: doc-comment new public members per the file's existing Design-decision-linking style (reference ADR-1/2/3/5); confirm full test suite still green. All new symbols carry ADR-linked doc comments; code was already clean at GREEN — no further extraction needed. +- [x] 5.2 Active-preset indication (Spec "Active Preset Indication (Optional)", ADR-6): OUT OF SCOPE for this change by design decision — preset rows carry plain names only, no marker. No task implements it; this line exists so the requirement isn't silently dropped from tracking. diff --git a/openspec/changes/android-auto-eq-presets/verify-report.md b/openspec/changes/android-auto-eq-presets/verify-report.md new file mode 100644 index 0000000..67ee07c --- /dev/null +++ b/openspec/changes/android-auto-eq-presets/verify-report.md @@ -0,0 +1,117 @@ +# Verification Report: android-auto-eq-presets + +**Mode**: Strict TDD | **Verdict**: PASS + +## Completeness + +All 21 tasks in `tasks.md` (Phases 1-5) are checked complete and match the live code. No task marked complete lacks a corresponding code/test change. + +## Test Execution Evidence (independently re-run, not trusted from apply-progress) + +| Command | Result | +|---|---| +| `flutter test test/servicios/navegacion_auto_test.dart --concurrency=1 --timeout=60s` (compact reporter) | 46/46 passed | +| Same file, `-r json`, counted `testDone` events | 46 (independently confirmed via JSON reporter, not just trusting the compact-reporter tally) | +| `flutter test test/estado/estado_radio_test.dart --concurrency=1 --timeout=60s` (favorite-groups regression suite, shared `playFromMediaId`/dispatch surface) | 22/22 passed (JSON `testDone` count also 22) | + +Apply-progress's claimed 46/46 for `navegacion_auto_test.dart` is accurate. The extra regression pass on `estado_radio_test.dart` (not part of apply-progress's own claims) also passes clean - no regression on the favorite-groups feature that shares the touched dispatch surface. + +## Load-Bearing Invariant: EQ selection never disturbs playback + +Traced `playFromMediaId` (`lib/servicios/servicio_audio.dart:781-820`) directly in the live file: +- The `esPresetMediaId(mediaId)` branch is the first statement inside the (now single) outer `try`, before `_fuenteNavegacionGlobal` is even read. +- It ends with an unconditional `return;` - genuinely cannot fall through to `reproducirPorMediaId`/`playMediaItem`. +- No `mediaItem` mutation, no `play()`/`setUrl()`/reconnect call anywhere in that branch - only `ServicioEcualizador()` construction and `aplicarPresetPorMediaId(...)`. + +Traced `aplicarPresetPorMediaId`'s actual signature (`lib/servicios/navegacion_auto.dart:364-371`, read directly, not from docstring): + +``` +Future aplicarPresetPorMediaId( + String id, { + required List presets, + required String? uuidActual, + required Future> Function() clavesPorEmisora, + required Future Function(PresetEcualizador) persistirPrincipal, + required Future Function(PresetEcualizador) aplicar, +}) +``` + +Confirmed: no `playMediaItem`, `mediaItem`, or `playbackState` parameter exists - structurally impossible to inject a playback seam. This matches the docstring claim and the dedicated structural test (`navegacion_auto_test.dart:755-784`). + +## Root Folder Count + +- `ConstructorArbolAuto.raiz()` (`lib/servicios/navegacion_auto.dart:171-176`) returns 4 folders, `Ecualizador` last, `playable: false`. +- `test/servicios/navegacion_auto_test.dart:226` - `expect(raiz, hasLength(4))` - confirmed updated (line number shifted from the apply-progress's original estimate of 225 to 226 due to file growth, as anticipated by the reviewer's warning; content matches). +- Line 241-242 additionally assert `raiz.last.id == idEcualizador` and `raiz.last.playable == false` (ADR-2 last-position + non-playable, beyond the base spec's minimum). +- Grepped the full `test/` tree for other `raiz()`/root-count assertions: only `navegacion_auto_test.dart` matches - confirms task 4.2's claim. + +## Persistence Seam + +Grepped both call sites in the diff: +- `servicio_audio.dart:800` - `persistirPrincipal: servicio.guardarPrincipal` -> `ServicioEcualizador.guardarPrincipal` (`servicio_ecualizador.dart:129-132`, writes SharedPreferences key `eq_preset_principal_v1`). +- No call to `EstadoEcualizador.cambiarPresetPrincipal` anywhere in the diff or the new code paths (confirmed via grep across `navegacion_auto.dart`/`servicio_audio.dart`). +- Matches ADR-4 exactly: handler + service seam, not the phone's `ChangeNotifier`. + +## debeAplicarPrincipalAhora Gate Parity + +Read both gates directly: +- `estado_ecualizador.dart:302-304`: `uuid == null || !_presetsEmisoraMap.containsKey(uuid)` +- `navegacion_auto.dart:348-351`: `uuidActual == null || !clavesPorEmisora.contains(uuidActual)` + +Also traced that `clavesPorEmisora` is populated from `servicio.cargar().porEmisora.keys`, and `servicio_ecualizador.dart`'s `cargar()` reads `porEmisora` from the same persisted per-station map that populates `EstadoEcualizador._presetsEmisoraMap` (`estado_ecualizador.dart:161-163`, `..addAll(config.porEmisora)`). The two gates are genuinely operating over the same underlying data, not just superficially similar in shape. + +## Unknown/Stale Preset ID + +`resolverPresetEq` returns `null` for unknown name / empty-after-prefix / non-`eq_preset:` id (`navegacion_auto.dart:332-340`). `aplicarPresetPorMediaId` returns immediately on `null` without invoking either seam. Test (`navegacion_auto_test.dart:731-753`) asserts `persistirLlamadas == 0` and `aplicarLlamadas == 0` for `'eq_preset:Inexistente'` - the assertion is on call counts, not just a test name; genuinely proves the no-op. + +## Active Preset Indication (Optional requirement) + +Confirmed OUT OF SCOPE as designed (ADR-6). Grepped `navegacion_auto.dart` for marker/selection-related terms - no half-built UI, no dead marker code, no leftover scaffolding. `itemPresetEq` emits only the plain preset name as `title`. + +## Deviations Self-Reported by apply-progress.md - Verified + +1. servicio_ecualizador.dart import added to servicio_audio.dart, not listed in task 3.3's enumerated file list. Confirmed real: `git diff` shows `+import 'servicio_ecualizador.dart';` at the top of the file; task 3.3's text only mentions verifying the `preset_ecualizador.dart` import. This is a genuine (harmless) task-description gap, not a code defect. +2. playFromMediaId restructured to a single outer try/catch. Confirmed real via `git diff`: previously `final fuente = _fuenteNavegacionGlobal; if (fuente == null) return;` sat OUTSIDE the try block; now both lines are inside the try, after the new `eq_preset:` branch. Behaviorally inert for the pre-existing `emisora:`/`grupo:` path - the null-check itself cannot throw, so moving it inside `try` changes nothing observable. Verified via the full `estado_radio_test.dart` (22/22) and `navegacion_auto_test.dart` (46/46, includes `reproducirPorMediaId` tests) suites, both green. + +## Diff Size Cross-Check + +`git diff --stat` on the three touched files: + +``` +lib/servicios/navegacion_auto.dart | 94 ++++++++++++- +lib/servicios/servicio_audio.dart | 26 +++- +test/servicios/navegacion_auto_test.dart | 226 ++++++++++++++++++++++++++++++- +3 files changed, 339 insertions(+), 7 deletions(-) +``` + +339 + 7 = 346 changed lines - matches apply-progress's self-reported figure exactly. Within the 300-420 forecast, under the 400-line budget. + +## Cleanliness Checks + +- No AI attribution strings found in the diff (`Co-Authored-By`, `Generated with`, `Claude`, `ChatGPT`, etc. - none matched). +- No stray `print`/`debugPrint` calls introduced. +- No `TODO`/`FIXME` markers introduced. +- No dead code found in the reviewed hunks. +- Working tree: `navegacion_auto.dart`, `servicio_audio.dart`, `test/servicios/navegacion_auto_test.dart` are modified-but-unstaged; `openspec/changes/android-auto-eq-presets/` is untracked. Nothing has been committed - confirmed via `git log` (HEAD is still `066fedb`, predating this change) and `git status`. + +## Spec Compliance Matrix + +| Spec Requirement | Status | Evidence | +|---|---|---| +| Browsable Media Tree (4 folders, Ecualizador included) | PASS | raiz() + test at line 226 | +| EQ Preset Browsable Folder (6 items, eq_preset:) | PASS | presetsEq/itemPresetEq + tests | +| EQ Preset Selection Applies Without Disturbing Playback | PASS | Structural signature proof + first-branch/unconditional-return trace | +| EQ Preset Persistence and Phone/Car Parity | PASS | guardarPrincipal call site confirmed, SP key shared with phone | +| Active Preset Indication (Optional) | PASS (scoped out) | ADR-6, no half-built code | + +## Design Coherence + +All 6 ADRs (ADR-1 through ADR-6) traced against live code - implementation matches design as documented. No undocumented deviation found beyond the two apply-progress already self-reported (both verified genuine and low-risk). + +## Issues Found + +None CRITICAL. None WARNING. None SUGGESTION beyond a purely cosmetic note: apply-progress's line-number references (e.g. "225" vs actual "226") drift slightly as the file grows - informational only, does not affect correctness or task completeness. + +## Final Verdict + +PASS - 0 CRITICAL, 0 WARNING, 0 SUGGESTION (one informational-only line-number drift note, not a defect). diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 55fff98..c207331 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -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 = { + '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:', () { + 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 => {}, + 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 => {}, + 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 = []; + final llamadasPersistir = []; + + await aplicarPresetPorMediaId( + 'eq_preset:${PresetEcualizador.pop.nombre}', + presets: PresetEcualizador.presets, + uuidActual: null, + clavesPorEmisora: () async => {}, + 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 '