83 lines
9.9 KiB
Markdown
83 lines
9.9 KiB
Markdown
# 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:<nombre>'`, `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<Set<String>> Function() clavesPorEmisora, required Future<void> Function(PresetEcualizador) persistirPrincipal, required Future<void> 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.
|