# 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.