Files
pluriwave/test/servicios/servicio_audio_eq_reapply_test.dart
FreeTLab 491585ad12 fix(eq): re-apply the equalizer after an audio-focus interruption
The equalizer stopped applying after another app interrupted audio (e.g. a
navigation app's voice prompt): play a station with EQ working, let the
prompt speak, resume -- the audio sounds flat until the station is
re-tapped.

debeReaplicarEcualizador only re-attaches the equalizer when the native
player session id actually changes. A short transient interruption keeps
the SAME session (no id rotation), so that trigger never fires, while
Android's AudioEffect framework can let a higher-priority client silently
disable this app's effect instance in the meantime.

Add reaplicarEcualizador() to ObjetivoAudioInterrumpible, implemented as a
thin delegate to the existing _activarEcualizador() (already the correct
idempotent setEnabled + re-push-gains path). ServicioAudioSession calls it
on resume-from-pause (after reanudar()) and on un-duck (after
setAtenuado(false)) -- additive to the existing session-id trigger, not a
replacement. The method takes no argument, so it can only re-assert
whatever enabled/disabled state the handler already holds -- an
interruption cycle with the equalizer OFF stays OFF.
2026-07-31 00:56:37 +02:00

290 lines
10 KiB
Dart

import 'package:audio_session/audio_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/servicios/servicio_audio_session.dart';
/// EQ audio-focus re-apply — pure decision predicate truth table.
///
/// Covers spec "EQ Audio-Focus Re-Apply Specification": whenever
/// `androidAudioSessionIdStream` emits a new native session id mid-playback
/// (audio-focus ducking by another app), the equalizer must be re-attached
/// and the current preset's gains re-pushed, gated on EQ availability so it
/// never races `_recrearPlayer()`'s teardown/rebuild.
///
/// [PluriWaveAudioHandler] cannot be instantiated in unit tests — its
/// constructor builds a real `just_audio.AudioPlayer` that requires platform
/// MethodChannels (confirmed by `servicio_audio_source_switch_test.dart`, "We
/// cannot instantiate the handler in unit tests"). The only unit-testable
/// surface is the pure, side-effect-free static predicate
/// `debeReaplicarEcualizador`, extracted per the design's "Interfaces /
/// Contracts" section. Listener wiring and the actual native re-attach are
/// covered by manual on-device QA (spec's Testability Matrix), not here.
void main() {
group('debeReaplicarEcualizador (EQ audio-focus re-apply predicate)', () {
test('rotation while playing returns true', () {
// Spec: Requirement "Session Id Rotation Triggers EQ Re-Apply" /
// Scenario "Session id rotates while playing".
expect(
PluriWaveAudioHandler.debeReaplicarEcualizador(
sessionId: 2,
ultimaSessionIdEq: 1,
eqDisponible: true,
),
isTrue,
reason: 'a genuine id rotation with EQ available must re-apply',
);
});
test('same id re-emitted returns false', () {
// Spec: Scenario "Same id re-emitted produces no redundant re-apply".
expect(
PluriWaveAudioHandler.debeReaplicarEcualizador(
sessionId: 1,
ultimaSessionIdEq: 1,
eqDisponible: true,
),
isFalse,
reason: 'a duplicate emission of the already-processed id is a no-op',
);
});
test('first activation / matching guard returns false', () {
// Spec: Scenario "First legitimate activation is not double-applied".
// Same shape as the duplicate-id case above, kept as a distinct named
// case per design's testing table to document intent: this represents
// the id emitted right after a station switch already set the guard
// (via _activarEcualizador() at the station-switch call site), not
// just an arbitrary repeated emission.
expect(
PluriWaveAudioHandler.debeReaplicarEcualizador(
sessionId: 1,
ultimaSessionIdEq: 1,
eqDisponible: true,
),
isFalse,
reason:
'the first post-station-switch emission must not double-apply '
'on top of the station-switch path\'s own _activarEcualizador()',
);
});
test('null id returns false', () {
// Spec: Requirement "Session Id Rotation Triggers EQ Re-Apply"
// (non-null precondition).
expect(
PluriWaveAudioHandler.debeReaplicarEcualizador(
sessionId: null,
ultimaSessionIdEq: 1,
eqDisponible: true,
),
isFalse,
reason: 'a null session id must never trigger a re-apply',
);
});
test('teardown gate returns false', () {
// Spec: Requirement "Re-Apply Is Gated On EQ Availability" / Scenario
// "Rotation during player teardown is safely skipped".
expect(
PluriWaveAudioHandler.debeReaplicarEcualizador(
sessionId: 9,
ultimaSessionIdEq: 1,
eqDisponible: false,
),
isFalse,
reason:
'while _eqDisponible is false (mid _recrearPlayer teardown), '
're-apply must be skipped entirely to avoid racing the rebuild',
);
});
});
// ── EQ re-apply after a SHORT audio-focus interruption ──────────────────
// debeReaplicarEcualizador only fires on a session-id CHANGE. A short
// transient interruption (a nav-app voice prompt) keeps the SAME player
// session id, so that trigger never fires and the equalizer stays
// silently disabled after Android lets another app's AudioEffect steal
// control. Fix: re-assert the equalizer on resume-from-pause and on
// un-duck too, via a new no-arg ObjetivoAudioInterrumpible.reaplicarEcualizador()
// that the handler implements as a thin delegate to the existing
// _activarEcualizador() (setEnabled + band gains, already correct).
//
// ServicioAudioSession is the orchestration layer under test here (the
// same layer servicio_audio_session_test.dart already covers) -- it is
// fully unit-testable, unlike PluriWaveAudioHandler itself.
group(
'ServicioAudioSession re-applies the equalizer on interruption resume '
'(no session-id change involved)',
() {
test(
'a pause-interruption cycle (begin -> end/resume) calls '
'reaplicarEcualizador exactly once, AFTER reanudar()',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.eventos,
['pausar', 'reanudar', 'reaplicar'],
reason:
'the re-apply must happen on RESUME, after reanudar() -- '
'never before, never on the begin/pause side',
);
},
);
test(
'a duck cycle (begin -> end/un-duck) calls reaplicarEcualizador '
'exactly once, AFTER setAtenuado(false)',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.duck),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.duck),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.eventos,
['atenuado:true', 'atenuado:false', 'reaplicar'],
reason:
'the re-apply must happen on UN-DUCK, after '
'setAtenuado(false)',
);
expect(objetivo.pausas, 0, reason: 'a duck never pauses');
},
);
test(
'with the equalizer switched OFF by the user, an interruption '
'cycle still only calls the SAME parameterless reassert -- '
'ServicioAudioSession has no way to force it on',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true
..eqActivo = false;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.estadosReaplicados,
[false],
reason:
'reaplicarEcualizador takes no boolean argument -- it can '
'only ask the handler to reassert whatever state it '
'ALREADY holds, never flip it on',
);
},
);
test(
'end without a prior begin/pause never calls reaplicarEcualizador '
'(mirrors "end sin pausa previa" -- no resume happened)',
() async {
final objetivo = _ObjetivoFake();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 0);
},
);
test(
'a permanent (unknown-type) focus loss never calls '
'reaplicarEcualizador -- there is no resume to re-assert after',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.unknown),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.unknown),
);
expect(objetivo.reaplicaciones, 0);
},
);
},
);
}
class _ObjetivoFake implements ObjetivoAudioInterrumpible {
bool intencion = false;
bool reproduciendo = false;
bool eqActivo = true;
int pausas = 0;
int reaplicaciones = 0;
final List<bool> atenuaciones = [];
final List<bool> estadosReaplicados = [];
/// Ordering log shared across every method — proves reaplicarEcualizador
/// fires at the EXACT point in the sequence the fix requires (after
/// reanudar()/setAtenuado(false)), not merely "at some point".
final List<String> eventos = [];
@override
bool get intencionReproducir => intencion;
@override
bool get estaReproduciendo => reproduciendo;
@override
Future<void> pausar() async {
pausas++;
reproduciendo = false;
intencion = false;
eventos.add('pausar');
}
@override
Future<void> reanudar() async {
reproduciendo = true;
intencion = true;
eventos.add('reanudar');
}
@override
Future<void> setAtenuado(bool atenuado) async {
atenuaciones.add(atenuado);
eventos.add('atenuado:$atenuado');
}
@override
Future<void> reaplicarEcualizador() async {
reaplicaciones++;
estadosReaplicados.add(eqActivo);
eventos.add('reaplicar');
}
}