Files
pluriwave/openspec/changes/archive/2026-07-19-android-auto-eq-presets/tasks.md
T
FreeTLab 9bfa9ac408 docs(openspec): archive android-auto-eq-presets
Merges its delta requirements into the android-auto-media base spec.
2026-07-19 14:19:11 +02:00

9.9 KiB

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 ConstructorArbolAutoidFavoritos/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 playFromMediaIdfuente == 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:35PresetEcualizador.presets is exactly 6 fixed entries (Flat, Rock, Pop, Bass Boost, Jazz, Voz).
  • test/servicios/navegacion_auto_test.dart:220-239hasLength(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

  • 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)
  • 1.2 [GREEN] navegacion_auto.dart: add _prefijoPresetEq = 'eq_preset:' top-level const (next to _prefijoEmisora, line 13); esPresetMediaId(id) => id.startsWith(_prefijoPresetEq).
  • 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.
  • 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).
  • 1.5 [RED] itemPresetEq test — id 'eq_preset:<nombre>', playable: true, title == preset.nombre. (Spec "Car requests the Ecualizador folder")
  • 1.6 [GREEN] navegacion_auto.dart: itemPresetEq(preset) => MediaItem(id: '$_prefijoPresetEq${preset.nombre}', title: preset.nombre, playable: true, extras: _contentStyleGrid).
  • 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")
  • 1.8 [GREEN] navegacion_auto.dart: presetsEq(presets) => presets.map(itemPresetEq).toList().

Phase 2: Core Implementation — resolve, gate, orchestrate

  • 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")
  • 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).
  • 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)
  • 2.4 [GREEN] navegacion_auto.dart: pure debeAplicarPrincipalAhora({uuidActual, clavesPorEmisora}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual).
  • 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)
  • 2.6 [RED] aplicarPresetPorMediaId — unknown/stale id: neither persistirPrincipal nor aplicar is invoked, function returns without throwing. (Spec "Unknown or stale preset id")
  • 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.
  • 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

  • 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).
  • 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.)
  • 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

  • 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.)
  • 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.
  • 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

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