fix(eq): resolve base-speaker preset live instead of pinning a stale copy
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s

_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.
This commit is contained in:
2026-07-10 23:54:01 +02:00
parent bfa95a1e57
commit 8f7ca8059b
10 changed files with 1026 additions and 2 deletions
+214
View File
@@ -333,6 +333,220 @@ void main() {
);
});
// ---------------------------------------------------------------------------
// Phase D: device disconnect / cold-start revert
// ---------------------------------------------------------------------------
group('device disconnect / cold-start revert (Phase D)', () {
const stationUuid = 'station-uuid-disconnect';
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
const builtinSpeaker = DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.altavozInterno,
nombre: 'Speaker',
);
test(
'D.1 disconnect resolves and applies the base (builtin_speaker) preset',
() async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.flat,
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: PresetEcualizador.bassBoost},
),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
// Connect BT first — active device now resolves to its own preset.
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.presetActual, equals(PresetEcualizador.bassBoost));
// Disconnect: platform emits the fallback device (builtin_speaker).
fakeDispositivo.emitirDispositivo(builtinSpeaker);
await Future<void>.delayed(const Duration(milliseconds: 50));
// builtin_speaker has no station/matrix/device entry -> global (flat).
expect(eq.presetActual, equals(PresetEcualizador.flat));
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.flat));
eq.dispose();
},
);
test(
'D.2 disconnect-created builtin_speaker entry must not mask a later '
'global preset change (bootstrap-skip proof, not a value tautology)',
() async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.rock,
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: PresetEcualizador.jazz},
),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
// BT active with its own preset (jazz).
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
// Disconnect to builtin_speaker (never seen before): first-seen
// bootstrap must NOT persist an entry for it, so a subsequent global
// change is never masked by a stale L3 copy.
fakeDispositivo.emitirDispositivo(builtinSpeaker);
await Future<void>.delayed(const Duration(milliseconds: 50));
// Change the global preset after the disconnect.
await eq.cambiarPresetPrincipal(PresetEcualizador.pop);
// Re-trigger resolution for builtin_speaker (e.g. another
// disconnect/device-changed event). If bootstrap wrote an entry on
// the first emit above, this second emit would resolve the STALE
// value (rock) instead of the fresh global (pop) — proving the
// masking bug. This assertion only passes when no L3 entry for
// builtin_speaker was ever created.
fakeDispositivo.emitirDispositivo(builtinSpeaker);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.presetsDispositivo.containsKey('builtin_speaker'), isFalse);
expect(eq.presetActual, equals(PresetEcualizador.pop));
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.pop));
eq.dispose();
},
);
test(
'D.3 cold start with no device connected applies base resolution, '
'never a stale persisted device preset',
() async {
final fakeAudio = FakeServicioAudio();
// No emitirDispositivo call: obtenerDispositivoActual() defaults to
// builtin_speaker, simulating a cold start with nothing connected.
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.rock,
eqMultiDeviceEnabled: true,
// Stale entry for a BT device that is NOT currently connected.
presetsDispositivo: {btDevice.id: PresetEcualizador.jazz},
),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
expect(eq.dispositivoActualId, equals('builtin_speaker'));
expect(eq.presetActual, equals(PresetEcualizador.rock));
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.rock));
eq.dispose();
},
);
test(
'D.4 connect -> disconnect -> reconnect cycle lands on the correct '
'preset at each step and never corrupts the device entry',
() async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.flat,
eqMultiDeviceEnabled: true,
presetsMatriz: {'$stationUuid:${btDevice.id}': PresetEcualizador.bassBoost},
),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
// Connect: matrix override wins. BT is never-seen, so its own
// first-seen bootstrap fires here too (unrelated to this change --
// only builtin_speaker is excluded from bootstrap).
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.presetActual, equals(PresetEcualizador.bassBoost));
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.bassBoost));
final btPresetTrasConectar = eq.presetsDispositivo[btDevice.id];
expect(btPresetTrasConectar, isNotNull);
// Disconnect: builtin_speaker has no matrix/station/device entry -> global.
fakeDispositivo.emitirDispositivo(builtinSpeaker);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.presetActual, equals(PresetEcualizador.flat));
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.flat));
// Reconnect: matrix override wins again.
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.presetActual, equals(PresetEcualizador.bassBoost));
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.bassBoost));
// The disconnect step must never have touched the BT device's own
// entry (locks existing test 5.5b: reconnect must not overwrite it).
expect(
eq.presetsDispositivo[btDevice.id],
equals(btPresetTrasConectar),
);
eq.dispose();
},
);
test(
'D.5 station override still wins over global on disconnect-to-base',
() async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.flat,
porEmisora: {stationUuid: PresetEcualizador.rock},
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: PresetEcualizador.jazz},
),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
// BT active. Note the station override (Level 2) already applies to
// every device on this station, including BT -- resolution is rock
// regardless of BT's own Level-3 entry, which is expected hierarchy
// behavior (Level 2 beats Level 3) and not itself under test here.
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
// Disconnect to builtin_speaker: station override (Level 2) must
// still beat global (Level 4) -- deterministic revert, not a flatten
// to global.
fakeDispositivo.emitirDispositivo(builtinSpeaker);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.presetActual, equals(PresetEcualizador.rock));
expect(fakeAudio.presetsAplicados.last, equals(PresetEcualizador.rock));
eq.dispose();
},
);
});
// ---------------------------------------------------------------------------
// Phase 5.55.6: First-seen device copies current preset
// ---------------------------------------------------------------------------