feat(auto): surface favorite groups as Android Auto sub-folders

Favoritos now renders non-empty custom groups as grupo:<id>
sub-folders (hidden when empty) with ungrouped stations left as
direct leaves, reusing the existing hijos() path so the zero-groups
case stays byte-identical to today's flat list.
This commit is contained in:
2026-07-19 13:42:25 +02:00
parent f9003436ea
commit f368bcc777
11 changed files with 812 additions and 1 deletions
@@ -0,0 +1,79 @@
# Apply Progress: Android Auto Favorite Groups
**Change**: android-auto-favorite-groups
**Mode**: Strict TDD
**Batch**: 1 of 1 (single delivery, no chaining — 291 changed lines, within 280-370 forecast)
## Completed Tasks
All 22 tasks from `tasks.md` are complete (Phase 1 through Phase 5).
- [x] 1.1-1.5 Foundation: snapshot seam + media-id scheme
- [x] 2.1-2.10 Core Implementation: pure tree builders
- [x] 3.1-3.3 Integration/Wiring
- [x] 4.1-4.3 Testing/Verification
- [x] 5.1 Cleanup (doc comments)
## Files Changed
| File | Action | What Was Done |
|------|--------|----------------|
| `lib/servicios/navegacion_auto.dart` | Modified | Added `grupos()` to `FuenteEmisorasAuto` abstract + `grupos` param on `actualizarSnapshot`; `FuenteEmisorasAutoLocal.grupos()` (snapshot-or-SQLite, try/catch→`[]`, mirrors `favoritos()`); `ConstructorArbolAuto`: `_prefijoGrupo`, `_maxGruposPorFavoritos`, `esCarpetaGrupo`, `itemGrupo`, `carpetasFavoritos`, `hijosGrupo`. |
| `lib/servicios/servicio_audio.dart` | Modified | `getChildren`: two new thin branches (`idFavoritos``carpetasFavoritos`, `esCarpetaGrupo``hijosGrupo`); removed now-unreachable `idFavoritos` case from `_listaParaCarpeta`'s switch. |
| `lib/estado/estado_radio.dart` | Modified | `cargarGruposFavoritos()` now pushes `_fuenteAuto?.actualizarSnapshot(grupos: _gruposFavoritos)`, mirroring the existing `favoritos:` push. |
| `test/servicios/navegacion_auto_test.dart` | Modified | New groups: `esCarpetaGrupo`, `itemGrupo`, `carpetasFavoritos` (regression parity, custom groups, empty-group omission, >50 cap), `hijosGrupo`, `resolver` collision-safety; `_FakeFuenteEmisorasAuto` updated to implement `grupos()`. |
| `test/estado/estado_radio_test.dart` | Modified | `_FuenteEmisorasAutoEspia` updated to implement `grupos()` and capture `ultimoGrupos`; existing live-snapshot test extended with an assertion that `cargarGruposFavoritos()` pushes the group snapshot. |
## TDD Cycle Evidence
| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR |
|------|-----------|-------|------------|-----|-------|-------------|----------|
| 1.1-1.2 (`esCarpetaGrupo`) | `navegacion_auto_test.dart` | Unit | 26/26 baseline | Written (compile-fail, symbol absent) | Passed | 4 cases (grupo:/favoritos/emisora:/empty) | Clean |
| 1.3-1.5 (seam extension) | `navegacion_auto_test.dart` | Unit | same run | Written (fake didn't compile) | Passed | N/A — structural interface extension | Clean |
| 2.1-2.2 (`itemGrupo`) | `navegacion_auto_test.dart` | Unit | same run | Written | Passed | Single scenario (spec defines one shape) | Clean |
| 2.3-2.7 (`carpetasFavoritos`) | `navegacion_auto_test.dart` | Unit | same run | Written (4 tests: regression parity, custom groups, empty omission, >50 cap) | Passed | 4 cases forced real filter/take/append logic (not fakeable with hardcoded return) | Clean |
| 2.8-2.9 (`hijosGrupo`) | `navegacion_auto_test.dart` | Unit | same run | Written (60-station cap+sort case, unknown-id case) | Passed | 2 cases (cap+sort vs. empty-for-unknown) | Clean |
| 2.10 (`resolver` collision) | `navegacion_auto_test.dart` | Unit | same run | Written | Passed | Single scenario (collision-free assertion) | Clean |
| 3.1-3.2 (`servicio_audio.dart` wiring) | N/A (zero-coverage dispatch layer, confirmed by 2 prior archived verify-reports) | N/A | N/A | N/A — thin dispatch, no test file exists for this layer per design precedent | Manual static review | N/A | Manual review: no unused symbols, no leftover `idFavoritos` switch case, prefixes non-colliding |
| 3.3 (`estado_radio.dart` push) | `estado_radio_test.dart` | Unit | 21/21 baseline (pre-change: file needed a compile fix for the new interface member regardless) | Written (new assertion on existing test) | Passed | N/A — mirrors existing `favoritos:` push pattern 1:1 | Clean |
### Test Summary
- **Total tests written**: 8 new test groups / 10 new test cases in `navegacion_auto_test.dart` + 1 new assertion in `estado_radio_test.dart`
- **Total tests passing**: 54/54 (33/33 `navegacion_auto_test.dart`, 21/21 `estado_radio_test.dart`) — corrected during verify, apply agent's original count of 55/55 (34/34) was inaccurate
- **Layers used**: Unit (all)
- **Approval tests** (refactoring): None — no refactoring-of-existing-behavior tasks; task 5.1 was pure doc-comment addition, verified via full re-run
- **Pure functions created**: `esCarpetaGrupo`, `itemGrupo`, `carpetasFavoritos`, `hijosGrupo` (all pure, no side effects, fully unit-tested)
## Deviations from Design
None — implementation matches design.md exactly, including:
- `grupo:<id>` media-id scheme, collision-free against `emisora:<uuid>` (verified via test)
- Empty custom groups hidden from the car tree (Design Decision "Empty groups hidden")
- `sin_asignar` stations stay as direct leaves at `Favoritos` root, never their own folder (Design Decision "Ungrouped favorites stay as direct leaves")
- `carpetasFavoritos` reuses the existing `hijos(idFavoritos, emisoras: ...)` path for `sin_asignar` leaves — confirmed via the regression-parity test (2.3) which asserts byte-identical output to `hijos()` when no custom groups exist. This directly satisfies the orchestrator's flagged risk: the no-custom-groups case is not reimplemented, it delegates to the exact same code path.
- `_maxGruposPorFavoritos = 50` kept as a separate constant from `_maxItemsPorCarpeta`, per design's tunability rationale
- Phone order preserved (`orden ASC, nombre ASC` from `obtenerGrupos()`, no re-sort applied)
- Handler dispatch (`servicio_audio.dart`) kept to two thin branches; all real logic lives in pure, tested functions in `navegacion_auto.dart`
`flutter build`/`flutter analyze`/`flutter gen-l10n` were NOT run per environment constraint (hangs) — task 4.3 [DEVIATION], same precedent as `openspec/changes/archive/2026-07-16-android-auto-media/tasks.md`. Manual static review completed: no unused symbols, prefix collision-safety verified by test (2.10), no leftover references to the removed `_listaParaCarpeta` switch case.
## Issues Found
One implementation detail not explicitly called out in tasks.md: `test/estado/estado_radio_test.dart`'s `_FuenteEmisorasAutoEspia` fake implements `FuenteEmisorasAuto` and would have failed to compile once the interface gained `grupos()`. Fixed as part of task 4.2's "run any estado_radio/servicio_audio test files if they exist" — updated the fake and added a real assertion (not just a compile fix) proving `cargarGruposFavoritos()` pushes the snapshot correctly, consistent with the file's existing test style for the sibling `favoritos:`/`misEmisoras:`/`todas:` pushes.
`MediaItem.playable` is nullable (`bool?`) in the `audio_service` package — the `>50 groups truncated` test originally used `!i.playable` which failed to compile; fixed to `i.playable != true`.
## Remaining Tasks
None. All 22 tasks complete.
## Workload / PR Boundary
- Mode: single PR (Review Workload Forecast: Medium risk, no chaining recommended, `Decision needed before apply: No`)
- Current work unit: N/A — full change delivered in one batch
- Boundary: starts from zero prior apply-progress, ends with all 22 tasks complete and 54/54 targeted tests green
- Estimated review budget impact: 291 changed lines (5 files: 3 lib, 2 test) — within the 280-370 forecast, under the 400-line budget
## Status
22/22 tasks complete. Ready for sdd-verify.