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.
290 lines
10 KiB
Dart
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');
|
|
}
|
|
}
|