Files
pluriwave/openspec/changes/archive/2026-07-11-persistence-corruption-guard/tasks.md
T
FreeTLab c65497e58a
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
docs(openspec): archive persistence-corruption-guard and promote its spec
Close the SDD cycle for the data-loss shielding change: verified pass
with warnings (0 critical, 10/10 scenarios with named tests, W1 fixed
post-verify), three stacked work units shipped plus the path-resolution
fix. The persistence-resilience capability spec is promoted to
openspec/specs/.
2026-07-11 17:34:36 +02:00

27 KiB

Tasks: Persistence Corruption Guard

Change: persistence-corruption-guard Delivery strategy: stacked-to-main work-unit commits (chain decision already resolved by orchestrator — see forecast below) File: openspec/changes/persistence-corruption-guard/tasks.md

Review Workload Forecast

Field Value
Estimated changed lines ~750-850 total (Phase A ~350-420, Phase B ~250-300, Phase C ~150-200)
400-line budget risk High (total); Phase A alone is borderline-High, B is Medium, C is Low-Medium
Chained PRs recommended Yes
Suggested split PR 1 (Alarms + shared helper) -> PR 2 (Stations) -> PR 3 (EQ)
Delivery strategy stacked-to-main work-unit commits
Chain strategy stacked-to-main

Decision needed before apply: No Chained PRs recommended: Yes Chain strategy: stacked-to-main 400-line budget risk: High

The 3-work-unit split (Alarms -> Stations -> EQ) IS the chaining answer: each phase ships as its own commit/PR, independently reviewable and independently shippable, gated by its own targeted suite. No further user decision needed before sdd-apply starts.

Suggested Work Units

Unit Goal Likely PR Notes
1 Shared tolerant-parse helper + Alarms degraded guard (Phase A) PR 1 Base: main. Creates persistencia_tolerante.dart; highest severity (auto-wipe via recalcularTodas)
2 Custom-station quarantine + IO-degraded guard (Phase B) PR 2 Base: PR 1 branch (stacked-to-main). Depends on persistencia_tolerante.dart from PR 1
3 EQ tolerant readers + logging (Phase C) PR 3 Base: PR 2 branch (stacked-to-main). Depends on persistencia_tolerante.dart from PR 1

If Phase A's actual diff exceeds ~450 lines, an optional 4th interim split (helper commit vs. alarms-consumption commit) is available but not required.

Stacked-to-main is the correct chain strategy here per the chained-pr skill's decision gate ("PR >400, each slice can land independently -> Stacked PRs to main") — each phase ships real user-facing value alone. When opening each work-unit PR, sdd-apply should follow chained-pr's Output Contract (dependency diagram with a current-PR marker, review budget, verification plan) and work-unit-commits' checklist (tests travel with the behavior commit, as already reflected in every RED/GREEN pairing below).

Phase A: Alarms (work unit 1 — worst blast radius, ships first) — COMPLETE (2026-07-11)

RED — shared tolerant-parse helper (D1)

  • A1 Create test/servicios/persistencia_tolerante_test.dart: parseListaTolerante skips 1 bad entry among 3 valid, returns the 3 survivors, logs the skip. (D1, Req: per-entry tolerant parsing, diagnostics) — done; plus a triangulation case (valid-shape entry whose parser itself throws) and a null-input case.
  • A2 Same file: parseMapaTolerante skips 1 bad VALUE among 3 map entries, original keys preserved untouched. (D1, Req: per-entry tolerant parsing) — done.
  • A3 Same file: both helpers on all-bad input return empty survivors, no exception escapes, still log. (D1, Req: degraded-read precondition) — done.
  • A4 Run flutter test test/servicios/persistencia_tolerante_test.dart — confirm RED (helpers undefined). — confirmed: compile error, parseListaTolerante/parseMapaTolerante not found.

GREEN — shared tolerant-parse helper

  • A5 Create lib/servicios/persistencia_tolerante.dart: parseListaTolerante<T> over an ALREADY-decoded List<dynamic>? (top-level string decode stays caller-owned — total-failure policy differs per subsystem, D3/D4 vs D5 vs D6); per-entry try/catch; calls the skip logger per skip. (D1) — done.
  • A6 Same file: parseMapaTolerante<V> analogous for Map<String, dynamic>?, preserves original keys (needed later for EQ's colon-delimited matrix keys). (D1) — done.
  • A7 Same file: registrarSaltoPersistencia({subsistema, detalle, razon}) -> debugPrint('[PluriWave][persistencia] ...'). No l10n string added. (Req: diagnostics are developer-facing only) — done.
  • A8 Re-run A1's test file — confirm GREEN. — confirmed: 5/5 pass.

RED — Alarms tolerant parse + degraded flag

  • A9 Create test/servicios/servicio_alarmas_corrupcion_test.dart with a local _PrefsEspia (mirrors servicio_alarmas_cache_test.dart's pattern, add an escriturasString counter). — done; also seeds via constructor + exposes lecturasString.
  • A10 Test A1 (no-thrash, explicit per orchestrator ask): seed alarmas_musicales_v1 with 2 valid + 1 malformed alarm entry; cargar() -> exactly 2 alarmas; call recalcularTodas() across 2 separate ticks; escriturasString grows by AT MOST 1 total (normalized cache stops the dirty-guard from re-firing every cycle). (D3, Req: per-entry tolerant parsing + healthy-behavior parity) — done; malformed entry = an id field of the wrong type (42) so AlarmaMusical.fromJson throws — this is what makes the test genuinely RED pre-fix (a non-Map entry alone is already silently filtered by the old .whereType<Map>() and does not exercise the bug).
  • A11 Test A2 (total fail, zero writes): seed '{bad'; cargar() -> alarmas empty; recalcularTodas() -> escriturasString unchanged. (D4, Req: degraded read suppresses automatic writes) — done; also asserts the raw string on disk is byte-identical to the original corrupt seed.
  • A12 Test A3 (explicit save restores authority): after A2's seed, guardarAlarma(...) writes once and clears the flag; a following real-change recalcularTodas() writes normally again. (D4, Req: degraded read — explicit mutation restores authority) — done.
  • A13 Test A4 (missing id never fabricated): seed 1 entry with no id among 2 valid; cargar() -> exactly 2 alarmas, none carrying a freshly generated id. (D2, Req: entry identity is never fabricated) — done.
  • A14 Run the new file — confirm RED. — confirmed: 3/4 new tests failed pre-fix (A10 got 0 survivors instead of 2; A11 got 1 spurious write instead of 0; A13 got 0 survivors instead of 2). A12 passed vacuously pre-fix since old code has no degraded flag to get stuck on — expected, not a rigor gap (its precondition setup is shared with A11, which independently proves the bug).

GREEN — Alarms implementation

  • A15 Restructure _parsear/_configActual (lib/servicios/servicio_alarmas.dart:59-110): top-level jsonDecode failure -> _lecturaAlarmasDegradada=true, _cache=empty config, _cacheRaw=raw unchanged (D4); success -> parse alarmas/vacaciones/excepciones each via parseListaTolerante (D1); any skips -> _cache=survivors, _cacheRaw=_serializar(survivors) normalized (D3); zero skips -> unchanged behavior, clear the flag if previously set (Req: suppression lifts on next successful read). — done; kept the per-field data['x'] as List? casts INSIDE the same try as jsonDecode (same boundary as the original code) so a container-level field of the wrong shape also degrades cleanly instead of throwing uncaught.
  • A16 Keep AlarmaMusical.fromJson's id: json['id'] as String unchanged (lib/modelos/alarma_musical.dart:145-147); doc-comment only — the caller-side per-entry try (A15) is what enforces skip-never-fabricate. (D2) — done (doc comment only, zero functional change; included despite not being spelled out verbatim in the orchestrator's file-scope summary line, since it is explicit task A16 and design's own File Changes table).
  • A17 Add bool _lecturaAlarmasDegradada = false; field; in recalcularTodas() (line 201) add if (_lecturaAlarmasDegradada) return config; BEFORE the existing dirty-guard. (D4) — done.
  • A18 In _guardar() (line 424) add _lecturaAlarmasDegradada = false; — every explicit mutation funnels through _guardar, restoring write authority. (D4, Req: explicit mutation restores authority) — done.
  • A19 Run servicio_alarmas_corrupcion_test.dart — confirm GREEN (A1-A4 all pass). — confirmed: 4/4 pass.

REFACTOR + suite gate

  • A20 Review naming/doc comments in _parsear/_configActual/persistencia_tolerante.dart; no behavior change. — done during GREEN (doc comments written alongside the implementation); re-reviewed, no further changes needed.
  • A21 Targeted suite: flutter test test/servicios/servicio_alarmas_cache_test.dart test/servicios/servicio_alarmas_proxima_test.dart test/servicios/servicio_alarmas_snooze_test.dart test/servicios/servicio_alarmas_corrupcion_test.dart test/servicios/persistencia_tolerante_test.dart — all green. NOTE: servicio_alarmas_android_test.dart, servicio_alarmas_android_instance_test.dart, servicio_alarmas_pre_notice_template_test.dart exercise ServicioAlarmasAndroid (native channel bridge, a different class untouched here) — confirmed via import check, excluded from this gate. (Req: healthy reads unchanged) — confirmed: 19/19 pass.
  • A22 flutter analyze --no-fatal-infos on the changed/created source files — clean. — confirmed: ran plain flutter analyze (stricter, no info-suppression) on all 5 changed/created files — "No issues found!".
  • A23 Commit as work unit 1: "Alarms — persistence corruption guard" (stacked-to-main, base: main). — done: commit b66eec0 fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms (5 files changed, 514 insertions, 24 deletions — above the ~350-420 forecast; still a single self-contained, independently-revertable work unit, no further split performed). Note: openspec/changes/persistence-corruption-guard/ left untracked/uncommitted, matching this repo's convention of separate docs(openspec) commits.

Phase B: Custom Stations (work unit 2) — COMPLETE (2026-07-11)

RED

  • B1 In test/estado/estado_radio_test.dart add a local _ArchivoEspia implements File fake (mirrors _PrefsEspia's implements+noSuchMethod pattern) with overridable exists/readAsString/writeAsString/rename/path, to force an IO failure independent of the OS. — done; injectable overrides via constructor + noSuchMethod fallback for the rest of File's interface, plus a writeAsStringCalls counter used by the write-guard assertion.
  • B2 Test B1 (partial survivors, covers 2 requirements): temp file (raw-JSON helper, not Emisora.toMap) with 2 valid station maps + 1 entry missing a required field + 1 entry missing uuid; after inicializar(), exactly 2 stations survive, none carrying a freshly generated uuid. (D5 partial, Req: per-entry tolerant parsing + entry identity never fabricated) — done via a new _crearArchivoCustomRaw helper; confirms exactly {custom-1, custom-2} survive.
  • B3 Test B2 (total parse-fail -> quarantine): temp file content '{bad'; after load, emisoras_custom.json.corrupt exists holding the ORIGINAL bytes, live path absent/empty. (D5 parse-fail) — done.
  • B4 Test B3 (add after B2 restores authority): after B2's quarantine, agregarEmisoraCustom(nueva) -> live file has ONLY nueva; .corrupt sidecar untouched. (D5, Req: explicit mutation restores authority) — done.
  • B5 Test B4 (IO error suppresses writes, NOT lifted by explicit add): _ArchivoEspia with readAsString throwing, exists() true; after load, agregarEmisoraCustom(...) -> the fake's writeAsString is NEVER invoked. NOTE (non-obvious, do not "fix"): unlike Alarms/EQ, station IO-fail suppression is intentionally NOT lifted by an explicit mutation this session — D5's rationale is avoiding clobbering a possibly-transiently-unreadable good file. (D5, Req: degraded read suppresses automatic writes) — done; also confirms the in-memory list still reflects the add (only the FILE write is suppressed, matching agregarEmisoraCustom's existing reassign-then-persist structure).
  • B6 Test B5 (sidecar-already-exists edge case): temp dir pre-seeded with an existing .corrupt file (content X); live file has a NEW parse failure; after load, .corrupt still holds X untouched, live path cleared. (D5 "else drop the live copy") — done.
  • B7 Run the file — confirm RED for the new/changed cases. — confirmed: 5/5 new tests failed pre-fix for the correct reasons (0 survivors instead of 2; sidecar missing; PathNotFoundException reading a sidecar that doesn't exist yet pre-quarantine; 1 spurious write instead of 0; live file not cleared when a sidecar already exists). All 13 pre-existing tests stayed green.

GREEN

  • B8 Split _cargarEmisorasCustom's catch (lib/estado/estado_radio.dart:503-521): read archivo.readAsString() in its own try — IO exception -> _customDegradado=true, _emisorasCustom=[], log, do not touch the file. (D5 IO-fail, Req: diagnostics) — done via a new _leerContenidoCustom(File) helper: exists()+readAsString() share one try; any throw sets the flag, clears the in-memory list, logs, and returns null (file untouched). Judgment call: _archivoCustom()'s own resolution (building the File/calling getApplicationDocumentsDirectory()) is called BEFORE this try, matching the task's literal scope ("read archivo.readAsString() in its own try"); the old code's single catch-all also covered that resolution step, so a hypothetical failure there is now uncaught. No test (old or new) exercises it and it is a stable path_provider call in production — flagged here for reviewer awareness, not fixed, to avoid unrequested scope creep.
  • B9 Decode/parse the read string via parseListaTolerante<Emisora> (D1); top-level jsonDecode/as List failure -> rename live file to '${path}.corrupt' (only if no sidecar exists yet; else delete the live file per D5), clear live path, _emisorasCustom=[], log — NO flag set. — done inside _cargarEmisorasCustom's second try, delegating the rename/delete choice to a new _ponerEnCuarentena(File) helper.
  • B10 Per-entry skips with survivors -> _emisorasCustom=survivors, no flag, no quarantine. (D5 partial) — done via parseListaTolerante<Emisora>(data, Emisora.fromMap, subsistema: 'emisoras_custom', coleccion: 'emisoras_custom'); _customDegradado explicitly cleared on this path (clean and partial reads both restore authority).
  • B11 Add bool _customDegradado = false; field; guard the top of _guardarEmisorasCustom() (line 523) with if (_customDegradado) return;. (D5) — done.
  • B12 Clear _customDegradado=false only inside _cargarEmisorasCustom on a clean/partial read — NOT inside agregarEmisoraCustom/eliminarEmisoraCustom (intentional asymmetry, see B5). (D5) — done; cleared in both the file-absent branch and the successful-parse branch; the parse-fail (quarantine) branch leaves the flag untouched (D5 says "NO flag set" — interpreted as neither set nor cleared, since the quarantine's cleared live path is itself the "fresh start" signal). agregarEmisoraCustom/eliminarEmisoraCustom unmodified.
  • B13 Run test/estado/estado_radio_test.dart — confirm new cases GREEN and existing custom-station cases (e.g. "incluye emisoras custom en el listado principal de inicio") unchanged. — confirmed: 18/18 pass (13 pre-existing + 5 new), diagnostic [PluriWave][persistencia] lines observed for every corrupt/degraded scenario.

REFACTOR + suite gate

  • B14 Review naming/doc comments; confirm agregarEmitoraCustom/eliminarEmitoraCustom compat aliases (lines 541-542, 551-552) still route through the guarded methods. — confirmed: both aliases (now ~622-624/632-634 after the new helpers were inserted) delegate unchanged to agregarEmisoraCustom/eliminarEmisoraCustom, which call the now-guarded _guardarEmisorasCustom(); importarConfig's own direct _guardarEmisorasCustom() call is covered by the same single choke point (an explicit import while IO-degraded is also suppressed, consistent with D5's stated asymmetry).
  • B15 Targeted suite: flutter test test/estado/estado_radio_test.dart — full file green (only Glob match for estado_radio*). (Req: healthy reads unchanged) — confirmed: 18/18 pass; Glob test/estado/estado_radio* returns only this one file.
  • B16 flutter analyze --no-fatal-infos — clean. — confirmed: ran plain flutter analyze on lib/estado/estado_radio.dart + test/estado/estado_radio_test.dart — "No issues found!".
  • B17 Commit as work unit 2: "Custom stations — persistence corruption guard" (stacked-to-main, base: work-unit-1 branch). Depends on persistencia_tolerante.dart from Phase A. — done: commit cbed0a6 fix(radio): quarantine corrupt custom-station files instead of silently emptying them (2 files changed, 312 insertions, 10 deletions), stacked directly on main on top of Phase A's 65c1ac2. Not pushed — orchestrator pushes. openspec/changes/persistence-corruption-guard/ left untracked/uncommitted, matching Phase A's convention of separate docs(openspec) commits.

Phase C: Equalizer (work unit 3) — COMPLETE (2026-07-11)

RED

  • C1 In test/servicios/servicio_ecualizador_test.dart add a new group using SharedPreferences.setMockInitialValues with raw corrupt strings (matches the file's existing convention). — done: new group "ServicioEcualizador — lectura tolerante y diagnostico (persistence-resilience)", with a debugPrint capture setUp/tearDown mirroring persistencia_tolerante_test.dart's pattern; added a top-level _presetJson(nombre) helper and a dart:convert/package:flutter/foundation.dart import.
  • C2 Test C1 (partial matrix survivors + colon round-trip): seed eq_presets_matriz_v1 with 2 valid "stationUuid:deviceId" entries + 1 malformed value; cargar() -> presetsMatriz has exactly 2, keys byte-identical to input. (D6, Req: per-entry tolerant parsing) — done; malformed entry is Map-shaped but has 'bandas': 'no-es-lista' (a String, not a List) so PresetEcualizador.desdeJson throws on the internal cast — this is what makes it genuinely RED pre-fix, not just a shape filter.
  • C3 Test C4 (colon round-trip with a skipped sibling, MAC-style deviceId): same shape but one surviving key's deviceId segment itself contains colons (e.g. bt_a2dp:AA:BB:CC:DD:EE:FF); confirm the split-on-first-colon convention (servicio_ecualizador.dart:96-98) is untouched by the skip. (D6, Req: per-entry tolerant parsing) — done; the MAC-style key 'station-X:bt_a2dp:AA:BB:CC:DD:EE:FF' survives byte-for-byte alongside a plain key, with 1 malformed sibling dropped. Since cargar() auto-invokes migrarClavesPlaceholder() first (which itself calls _leerMapa and splits every matrix key on the first colon), this test also proves that code path doesn't choke or misfire on a multi-colon surviving key.
  • C4 Test C2 (total corrupt map -> empty, explicit save still persists): seed eq_preset_por_dispositivo_v1='{bad'; cargar() -> presetsDispositivo empty; guardarPresetDispositivo(...) still writes successfully (no flag exists for EQ). (D6, Req: explicit mutation restores authority) — done.
  • C5 Test C3 (corrupt principal -> flat + logged): seed eq_preset_principal_v1='{bad'; cargar().principal == PresetEcualizador.flat (existing fallback) AND a [PluriWave][persistencia] log line is now emitted (currently silent). (Req: diagnostics are developer-facing only) — done.
  • C6 Test: _leerMapaStrings (nombresDispositivos) partial survivors — 1 bad value among 2 valid names. (D6, Req: per-entry tolerant parsing) — done; bad value is 12345 (not a String).
  • C7 Run the file — confirm RED for the new cases only; existing 20 cases stay green (untouched). — confirmed: 4/5 new tests genuinely RED pre-fix — C2/C3 got an empty Set instead of 2 survivor keys, C6 got {} instead of 2 survivor names, C5 got logs.any(...) == false (no log line existed yet). C4 passed VACUOUSLY pre-fix — exactly mirrors Phase A's A12 precedent: EQ never had a degraded-write flag in either the old or new code, so "guardarPresetDispositivo still writes successfully" was already true beforehand; its role is characterization/regression-lock for D6's "no flag" design choice (matching the spec's own EQ scenario wording), not a bug-fix proof — the genuine per-entry-tolerance bug evidence comes from C2/C3/C6. All 20 pre-existing cases stayed green throughout.

GREEN

  • C8 Convert _leerMapa (lib/servicios/servicio_ecualizador.dart:231-248) to delegate per-entry conversion to parseMapaTolerante<PresetEcualizador> (D1) — shared by presetsPorDispositivo and presetsMatriz; keep the existing outer try around jsonDecode for total-failure (log + return {}, unchanged behavior). — done; outer catch now also calls registrarSaltoPersistencia before returning {} (previously silent) — consistent with the design's "log degraded reads" statement and Phase A's A15 precedent of logging total-failure catches beyond a single dedicated test's literal scope.
  • C9 Convert _leerMapaStrings (lines 260-269) the same way -> parseMapaTolerante<String>. — done; outer catch also now logs.
  • C10 Convert _leerPresetsPorEmisora (lines 293-311) the same way -> parseMapaTolerante<PresetEcualizador>. — done; outer catch also now logs. NOTE: no NEW dedicated corruption test targets this method directly (C2-C6 exercise _leerMapa/_leerMapaStrings/_leerPresetPrincipal, not _leerPresetsPorEmisora) — its correctness rests on (a) parseMapaTolerante's own exhaustive Phase A unit tests, (b) being byte-for-byte the same conversion shape as _leerMapa which C2/C3 DO exercise directly, and (c) existing healthy-path regression tests (4.1h, 4.1i) proving the wiring didn't regress normal reads. Flagged for reviewer awareness, matching Phase B's B8 precedent of documenting an intentionally-unexercised path rather than expanding scope beyond the assigned tasks.
  • C11 Add a skip-logger call inside _leerPresetPrincipal's existing catch (line 288) — single-value key, logging-only change, no structural change. (D6, Req: diagnostics) — done.
  • C12 No flag/quarantine anywhere in this file (D6 intentional asymmetry vs. Alarms/Stations — EQ is explicit-only, presets trivially recreatable); read-modify-write callers (guardarPresetDispositivo, guardarPresetMatriz, guardarNombresDispositivos, guardarPorEmisora) need no code change — they already read via the now-fixed tolerant readers. — confirmed: no flag field added; all guardarXxx methods and guardarConfiguracion left byte-for-byte unmodified; C4 independently proves the "no flag" contract holds (explicit write after total corruption still lands on disk).
  • C13 Run test/servicios/servicio_ecualizador_test.dart — confirm new cases GREEN and all 20 pre-existing cases (multi-device, nombresDispositivos, placeholder migration) pass unmodified. — confirmed: 25/25 pass (20 pre-existing + 5 new).

REFACTOR + suite gate

  • C14 Review naming/doc comments; confirm migrarClavesPlaceholder (itself calls _leerMapa/_leerMapaStrings, lines 83-126) is unaffected — its own 5.x test group still passes. — confirmed: all 7 tests in the placeholder-migration group (5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.9) pass unchanged; C3 additionally exercises this exact call path (via cargar()) with a multi-colon surviving key with no incident.
  • C15 Targeted suite: flutter test test/servicios/servicio_ecualizador_test.dart — full file green (only Glob match for servicio_ecualizador*). (Req: healthy reads unchanged) — confirmed: 25/25 pass.
  • C16 flutter analyze --no-fatal-infos — clean. — confirmed: ran plain flutter analyze on lib/servicios/servicio_ecualizador.dart + test/servicios/servicio_ecualizador_test.dart — "No issues found!".
  • C17 Commit as work unit 3: "EQ presets — persistence corruption guard" (stacked-to-main, base: work-unit-2 branch). Depends on persistencia_tolerante.dart from Phase A. — done: commit 5e53a6c fix(eq): keep valid presets when stored maps are partially corrupt (2 files changed, 231 insertions, 15 deletions), stacked directly on main on top of Phase B's 13ad736. NOT pushed — orchestrator pushes.

Phase D: Final Sweep (not a separate work unit — closes the change) — COMPLETE (2026-07-11)

  • D1 flutter analyze --no-fatal-infos — zero issues across all 5 changed/created source files (persistencia_tolerante.dart, servicio_alarmas.dart, alarma_musical.dart, estado_radio.dart, servicio_ecualizador.dart). — confirmed: ran plain flutter analyze with NO path argument (whole-project sweep, strictly broader than the per-file requirement) — "No issues found!" (5.5s).
  • D2 Targeted full sweep in one command: flutter test test/servicios/persistencia_tolerante_test.dart test/servicios/servicio_alarmas_cache_test.dart test/servicios/servicio_alarmas_proxima_test.dart test/servicios/servicio_alarmas_snooze_test.dart test/servicios/servicio_alarmas_corrupcion_test.dart test/estado/estado_radio_test.dart test/servicios/servicio_ecualizador_test.dart — all green. — confirmed: 62/62 pass in one run across all 7 files (persistencia_tolerante_test.dart, servicio_alarmas_cache_test.dart, servicio_alarmas_proxima_test.dart, servicio_alarmas_snooze_test.dart, servicio_alarmas_corrupcion_test.dart, estado_radio_test.dart, servicio_ecualizador_test.dart).
  • D3 If a full flutter test run is attempted instead and hangs, fall back to D2's explicit file list — known intermittent hang, pre-existing/unrelated (per sdd-init/pluriwave cache and eq-audiofocus-reapply precedent); do not block delivery on it. — did not attempt a full flutter test run given this known issue; went straight to D2's targeted file list.
  • D4 Optional, non-blocking on-device/emulator sanity: launch once with a pre-existing (pre-change) alarm + custom station + EQ preset already saved; confirm all three still load correctly. Skip if no device/emulator is available — every behavior here is already Dart-testable and covered above; no l10n, no Kotlin changes in this capability. — left unchecked: no device/emulator available in this environment; explicitly optional per this task's own skip clause.

Requirement Traceability

Spec Requirement Satisfied by
Per-entry tolerant parsing preserves valid siblings A10/A15, B2/B9-10, C2-C3/C6/C8-C10
Entry identity is never fabricated A13/A16, B2/B9
Degraded read suppresses automatic writes / restores on authority A11-A12/A17-A18, B4-B5/B11-B12, C4/C12
Healthy reads are behaviorally unchanged A21, B15, C13/C15, D2
Diagnostics are developer-facing only, no l10n A7, B8-B9, C5/C11, D1

Dependency Summary

Phase A -> Phase B -> Phase C -> Phase D, strictly sequential (stacked-to-main: each branch bases on the previous). Within each phase: RED sub-tasks are independent of each other (parallel); GREEN is sequential and depends on all of that phase's RED tests existing; REFACTOR + suite gate is sequential and closes the phase. Phase B and Phase C both depend on persistencia_tolerante.dart existing from Phase A — no other cross-phase code dependency.