9.9 KiB
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; noeq_preset:prefix exists yet.lib/servicios/navegacion_auto.dart:119-256ConstructorArbolAuto—idFavoritos/idTodas/idMisEmisoras(123-125),_idsCarpetas(127, do NOT addidEcualizadorhere — it needs its own dedicated branch, not the generichijos()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-761getChildren— root check, thenfuente == nullguard, thenidFavoritos/esCarpetaGrupo/default branches.lib/servicios/servicio_audio.dart:778-798playFromMediaId—fuente == nullguard first, then delegates toreproducirPorMediaId; zero existing test coverage for this dispatch layer (notest/servicios/servicio_audio_test.dartexists).lib/servicios/servicio_audio.dart:183emisoraActual— public field onPluriWaveAudioHandler, directly readable (no getter indirection needed foruuidActual).lib/servicios/servicio_audio.dart:580-601aplicarPreset— headless-safe seam, touches only_presetActual/native EQ, nevermediaItem/playbackState.lib/servicios/servicio_ecualizador.dart:129-132guardarPrincipal— writes SP keyeq_preset_principal_v1(line 39).lib/estado/estado_ecualizador.dart:295-310cambiarPresetPrincipal— confirmed gate:uuid == null || !_presetsEmisoraMap.containsKey(uuid)(302-304) is the exact logicdebeAplicarPrincipalAhoramust mirror.lib/modelos/preset_ecualizador.dart:35—PresetEcualizador.presetsis 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-idequals()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:esPresetMediaIdtable —'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]
raiztest (update existingtest/servicios/navegacion_auto_test.dart:220-239group):hasLength(3)→hasLength(4); id set gainsConstructorArbolAuto.idEcualizador; add explicit assertion thatraiz().last.id == ConstructorArbolAuto.idEcualizador(ADR-2, last position) andraiz().last.playable == false. - 1.4 [GREEN]
navegacion_auto.dart: addidEcualizador = 'ecualizador'const (123-125 block, NOT added to_idsCarpetas); append_carpeta(idEcualizador, 'Ecualizador')as the last entry inraiz()(line 152-156). - 1.5 [RED]
itemPresetEqtest — 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]
presetsEqtest — givenPresetEcualizador.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]
resolverPresetEqtable — 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)— guardesPresetMediaId, strip prefix, empty→null, exact-name loop match→preset else null (mirrorsresolver()shape, line 193). - 2.3 [RED]
debeAplicarPrincipalAhoratable —uuidActual: null→true;uuidActualnot inclavesPorEmisora→true;uuidActualinclavesPorEmisora→false. (ADR-5, mirrorsestado_ecualizador.dart:302-304) - 2.4 [GREEN]
navegacion_auto.dart: puredebeAplicarPrincipalAhora({uuidActual, clavesPorEmisora}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual). - 2.5 [RED]
aplicarPresetPorMediaId— known preset:persistirPrincipalcalled once with resolved preset;aplicarcalled once when gate is true;aplicarNOT called when gate is false (per-station override case). (ADR-3, ADR-5) - 2.6 [RED]
aplicarPresetPorMediaId— unknown/stale id: neitherpersistirPrincipalnoraplicaris invoked, function returns without throwing. (Spec "Unknown or stale preset id") - 2.7 [RED] Structural invariant test — assert
aplicarPresetPorMediaId's signature exposes ONLYpersistirPrincipal/aplicar/uuidActual/clavesPorEmisoraseams (noplayMediaItem/mediaItem/playbackStateparameter exists to inject); document this as the load-bearing non-playback proof per ADR-3. - 2.8 [GREEN]
navegacion_auto.dart: implementaplicarPresetPorMediaId(id, {required presets, required uuidActual, required Future<Set<String>> Function() clavesPorEmisora, required Future<void> Function(PresetEcualizador) persistirPrincipal, required Future<void> Function(PresetEcualizador) aplicar})— resolve viaresolverPresetEq;null→return; elseawait 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-761getChildren: insertif (parentMediaId == ConstructorArbolAuto.idEcualizador) return constructor.presetsEq(PresetEcualizador.presets);immediately after the root-id check and BEFOREfinal fuente = _fuenteNavegacionGlobal;(no data source needed — compile-time constant list, per design's Integration Points). - 3.2
servicio_audio.dart:778-798playFromMediaId: insert anesPresetMediaId(mediaId)branch as the FIRST statement in the method body, BEFOREfinal 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 + unconditionalreturnmeans it can never fall through toreproducirPorMediaId.) - 3.3 Add
import '../modelos/preset_ecualizador.dart';toservicio_audio.dartif not already present (check before adding —aplicarPreset's existing signature at line 580 already takesPresetEcualizador, 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 updatedhasLength(4)root test. (46/46 passing, independently re-run twice.) - 4.2 Grep the full
test/tree for any otherraiz()/root-folder-count assertions outsidenavegacion_auto_test.dart(baseline found none as of this writing — confirm still true before merge). Confirmed: onlynavegacion_auto_test.dartmatches. - 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 withemisora:/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.