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 atenuaciones = []; final List 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 eventos = []; @override bool get intencionReproducir => intencion; @override bool get estaReproduciendo => reproduciendo; @override Future pausar() async { pausas++; reproduciendo = false; intencion = false; eventos.add('pausar'); } @override Future reanudar() async { reproduciendo = true; intencion = true; eventos.add('reanudar'); } @override Future setAtenuado(bool atenuado) async { atenuaciones.add(atenuado); eventos.add('atenuado:$atenuado'); } @override Future reaplicarEcualizador() async { reaplicaciones++; estadosReaplicados.add(eqActivo); eventos.add('reaplicar'); } }