_onDispositivoCambiado() bootstrapped a device-level preset entry for every never-seen device id, including the built-in speaker. That persistent level-3 entry masked later global-preset edits (level 3 beats level 4 on every resolution), so disconnecting a BT device or cold-starting without one could leave the EQ stuck on an outdated copy instead of the current global preset. The base speaker is now excluded from the first-seen bootstrap: disconnect and cold start always resolve through the live hierarchy. BT/wired/USB devices keep their bootstrap behavior unchanged.
6.4 KiB
Delta for multi-device-eq
No prior openspec/specs/multi-device-eq/spec.md exists. This delta adds
requirements as new (ADDED), scoped to disconnect/cold-start revert only.
Connect-path and 4-level-hierarchy requirements are out of scope (already
covered by the passing test suite).
ADDED Requirements
Requirement: Device-Event Resolve-and-Apply
The system MUST treat every device-change event — connect or disconnect —
identically: set the now-active device as current, re-resolve via the
existing 4-level hierarchy (matrix -> station -> device -> global), and
APPLY via ServicioAudio.aplicarPreset(). The system MUST NOT branch on
event type; no add/remove discriminator exists on the event, and none is
required.
Scenario: Connect applies the device's own preset
- GIVEN
_eqMultiDeviceEnabledis true and a station is active - WHEN a device-change event arrives for a device with a resolvable preset
- THEN the resolved preset becomes
presetActual - AND
audio.aplicarPreset()is called with it (asserted viaFakeServicioAudio.presetsAplicados)
Scenario: Disconnect resolves and applies the base preset
- GIVEN
_eqMultiDeviceEnabledis true, a BT device is active with its own resolved preset, andbuiltin_speakerresolves to a distinct preset - WHEN a device-change event arrives carrying
builtin_speaker(simulating disconnect-to-base viaFakeServicioDispositivoAudio.emitirDispositivo, which carries no add/remove flag) - THEN
_dispositivoActualIdbecomes'builtin_speaker' - AND the hierarchy resolution for
builtin_speakeris applied - AND
audio.aplicarPreset()records it as the last entry inpresetsAplicados(proves push, not state-only mutation)
Requirement: First-Seen Bootstrap Must Not Hijack Base-Device Resolution
The system MUST NOT force-copy a stale resolved preset into
presetsDispositivo['builtin_speaker'] on disconnect when it has no
existing entry. The base speaker is excluded from first-seen bootstrap
entirely: no presetsDispositivo['builtin_speaker'] entry is ever
auto-created, so the base device always resolves live through the
hierarchy (a persistent device-level entry would mask later global-preset
edits, since level 3 beats level 4 on every subsequent resolution).
Scenario: Disconnect to a never-seen builtin_speaker resolves from the hierarchy
- GIVEN
_eqMultiDeviceEnabledis true,presetsDispositivohas no entry forbuiltin_speaker, a BT device is active resolved tojazz, and global_presetPrincipalisrock - WHEN the device-change event carries
builtin_speaker - THEN no
presetsDispositivo['builtin_speaker']entry is created, and resolution falls through to the global presetrock, NOTjazz - AND
audio.aplicarPreset()is called withrock - AND a later change of the global preset while on
builtin_speakerre-resolves to the new global value (no stale level-3 entry masks it)
Requirement: Cold Start Applies Base Resolution, Never a Stale Persisted Preset
The system MUST guarantee that cold start with no external device connected
applies the phone/base resolution for builtin_speaker through
audio.aplicarPreset(), never a stale persisted preset for a
previously-connected, now-inactive device.
Scenario: Cold start with no device connected applies the base preset
- GIVEN
_eqMultiDeviceEnabledis true,presetsDispositivohas a stale entry for a BT device id that is NOT currently connected, andobtenerDispositivoActual()resolves tobuiltin_speaker(its default when no device was ever emitted) - WHEN
cargarPersistido()runs (_sembrarDispositivoActual()->_onDispositivoCambiado()) - THEN
_dispositivoActualIdbecomes'builtin_speaker' - AND
presetActualis the hierarchy resolution forbuiltin_speaker - AND
audio.aplicarPreset()records that resolution, NOT the stale BT device's preset
Requirement: Connect-Disconnect-Reconnect Cycle Lands on the Correct Preset at Each Step
The system MUST resolve and apply the correct preset at every step of a connect -> disconnect -> reconnect cycle, honoring station and matrix overrides, and MUST NOT let disconnect corrupt state such that reconnect resolves incorrectly.
Scenario: Full cycle honors station/matrix overrides at each step
- GIVEN a matrix override for
(stationUuid, btDevice.id)=bassBoost, andbuiltin_speakerresolves to globalflat - WHEN the device stream emits: BT device (connect) ->
builtin_speaker(disconnect) -> BT device (reconnect) - THEN after connect,
presetActualisbassBoost; after disconnect,flat; after reconnect,bassBoostagain — each recorded as the last entry inpresetsAplicados - AND the BT device's
presetsDispositivoentry is UNCHANGED by the disconnect step (consistent with existing test 5.5b: reconnect must not overwrite a user-saved device preset)
Requirement: Toggle-Off Behavior Is Byte-for-Byte Unchanged
When _eqMultiDeviceEnabled is false, device-change events MUST NOT alter
resolution: no device subscription is active, no first-seen bootstrap runs,
and resolution stays 2-level (station -> global) only.
Scenario: Toggle off ignores device events entirely
- GIVEN
_eqMultiDeviceEnabledis false - WHEN a device-change event is emitted (any device, including
builtin_speaker) - THEN
_onDispositivoCambiadoreturns immediately with no state change - AND
presetEfectivo(stationUuid, deviceId)still resolves via the 2-level path exactly as before this change - AND
presetsAplicadosgains no new entry from the event
Testability
All scenarios are Dart-testable via existing fakes in
test/helpers/fakes.dart — no new fake infrastructure required:
FakeServicioDispositivoAudio.emitirDispositivo(dispositivo)— pushes anyDispositivoAudiothroughonDispositivoCambiado; used identically for simulated connect and disconnect (no discriminator on the real stream).FakeServicioDispositivoAudio.obtenerDispositivoActual()— defaults tobuiltin_speaker; used for the cold-start scenario.FakeServicioAudio.presetsAplicados— proves gains were PUSHED viaaplicarPreset(), not just mutated in state. Every scenario asserts this list, not onlypresetActual.FakeServicioEcualizadorconstructor params (principal,porEmisora,presetsDispositivo,presetsMatriz) — seed the hierarchy as the existing Phase 5 suite already does.
Localization
No new l10n strings are introduced by this change.