import 'package:flutter_test/flutter_test.dart'; import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState; import 'package:pluriwave/servicios/servicio_audio.dart'; import '../helpers/handlers_audio.dart'; /// eq-estado-unico — the equalizer's on/off flag gets a SINGLE owner. /// /// Reported bug: «alguna emisora parece que esta con la ecualizacion activada /// (suena muy alto) pero con el boton desactivado», and «pulsando sobre el /// boton de ecualizar en Android Auto tampoco activaba ni desactivaba». /// /// The flag used to live in three independent copies — the handler's /// hardcoded `_ecualizadorActivo = true`, `EstadoEcualizador._activo`, and /// SharedPreferences — and the persisted value only ever reached the handler /// through `EstadoEcualizador.cargarPersistido()`, which a headless Android /// Auto engine (no Activity, no Provider tree, no `EstadoRadio._init`) never /// runs. So in the car the handler played with the equalizer forced ON while /// disk and the phone UI both said OFF. /// /// NOTE on testability: the long-standing comment in `servicio_audio.dart` /// claiming `PluriWaveAudioHandler` "cannot be instantiated in a unit test /// (a real just_audio.AudioPlayer needs platform MethodChannels)" is WRONG /// as of just_audio 0.9.46 — `AudioPlayer`'s constructor resolves its /// platform lazily and never becomes active without a `setUrl`, so the /// handler constructs fine here and every EQ path that does not touch the /// native effect is directly exercisable. That is what the dispatch tests /// below rely on; only the native `setEnabled`/`setGain` calls stay out of /// reach (they sit behind `_eqDisponible`, which is `false` off-device). void main() { TestWidgetsFlutterBinding.ensureInitialized(); final crearHandler = registrarHandlersLiberables(); group('estadoEqInicial (A — seed the handler from disk on every engine)', () { test('adopts the persisted value when there is one', () { expect(estadoEqInicial(persistido: false), isFalse); expect(estadoEqInicial(persistido: true), isTrue); }); test('defaults to ON only when nothing was ever persisted', () { expect( estadoEqInicial(persistido: null), isTrue, reason: 'a first install keeps the historical default (EQ on)', ); }); }); group('registrarHandler (A — seeding)', () { test('consults the injected read port exactly once and seeds the handler ' 'with the persisted value', () async { final handler = crearHandler(); var lecturas = 0; registrarHandler( handler, leerEqActivoPersistido: () async { lecturas++; return false; }, ); await Future.delayed(Duration.zero); expect(lecturas, 1, reason: 'exactly one disk read per engine start'); expect( handler.ecualizadorActivo, isFalse, reason: 'the handler must adopt what the phone UI persisted', ); }); test('a read failure leaves the handler on the safe default instead of ' 'propagating', () async { final handler = crearHandler(); registrarHandler( handler, leerEqActivoPersistido: () async => throw StateError('sin disco'), ); await Future.delayed(Duration.zero); expect(handler.ecualizadorActivo, isTrue); }); test('without a read port the handler is left untouched (widget tests, ' 'fakes)', () async { final handler = crearHandler(); await handler.setEcualizadorActivo(false); registrarHandler(handler); await Future.delayed(Duration.zero); expect(handler.ecualizadorActivo, isFalse); }); }); /// A, CONSTRUCTION-WINDOW half. `_eqActivoPersistido` (the module-level /// cache behind `_ecualizadorActivo = estadoEqInicial(persistido: ...)`) /// had zero coverage on BOTH sides: replacing that initialiser with the old /// hardcoded `= true` left the suite green, and so did deleting the /// `_eqActivoPersistido = activo` write in `_aplicarEcualizadorActivo`. /// /// The window it closes is real: `AudioService.init` builds the handler /// through its `builder` callback and only AFTER that future resolves does /// `main.dart` reach `registrarHandler`. A car tap landing inside that /// window would otherwise hit a handler whose flag had never seen disk. group('A (construction window) — a handler built after a disk read', () { test('a handler constructed AFTER a read port has already answered ' 'starts from the persisted value, not from a hardcoded default', () async { // One engine does the read `registrarHandler` performs in main.dart. final primero = crearHandler(); // Pin the module cache to the OPPOSITE value first. Without this the // test passes for the wrong reason: whatever ran before may already // have left the cache on `false`, so deleting the disk→cache write in // `_sembrarEcualizadorDesdeDisco` would still leave this green. Seeding // does NOT write the cache (`_aplicarEcualizadorActivo` returns before // it when `persistir: false`), so after this pin that write is the only // path that can bring the cache back down to `false`. await primero.setEcualizadorActivo(true); registrarHandler(primero, leerEqActivoPersistido: () async => false); await Future.delayed(Duration.zero); expect(primero.ecualizadorActivo, isFalse); // Now the construction window: a handler built by `AudioService.init`'s // builder, with no port of its own yet. final segundo = crearHandler(); expect( segundo.ecualizadorActivo, isFalse, reason: 'a car tap landing before registrarHandler must not find the ' 'equalizer forced on while disk says off', ); }); test('the cache follows what the handler itself writes, in both ' 'directions', () async { final handler = crearHandler(); registrarHandler(handler); await handler.setEcualizadorActivo(false); expect( crearHandler().ecualizadorActivo, isFalse, reason: 'the write side of the cache: a toggle must be visible to the ' 'next handler built on this engine', ); await handler.setEcualizadorActivo(true); expect(crearHandler().ecualizadorActivo, isTrue); }); }); group('B — the handler persists its OWN toggle', () { test('an eq toggle writes through the injected port even with no ' 'EstadoEcualizador in play', () async { final handler = crearHandler(); final escrituras = []; registrarHandler( handler, guardarEqActivoPersistido: (activo) async => escrituras.add(activo), ); await handler.setEcualizadorActivo(false); await handler.setEcualizadorActivo(true); expect( escrituras, [false, true], reason: 'a car/notification toggle must survive a process restart ' 'without any UI object existing', ); }); test('seeding from disk does NOT write back to disk', () async { final handler = crearHandler(); final escrituras = []; registrarHandler( handler, leerEqActivoPersistido: () async => false, guardarEqActivoPersistido: (activo) async => escrituras.add(activo), ); await Future.delayed(Duration.zero); expect(handler.ecualizadorActivo, isFalse); expect(escrituras, isEmpty); }); test('a failing write port never breaks the toggle', () async { final handler = crearHandler(); registrarHandler( handler, guardarEqActivoPersistido: (_) async => throw StateError('disco lleno'), ); await handler.setEcualizadorActivo(false); expect(handler.ecualizadorActivo, isFalse); }); }); group('decidirToggleEq (C — the customAction decision)', () { test('flips the current value', () { expect( decidirToggleEq(activoActual: true, eqDisponible: true).nuevoActivo, isFalse, ); expect( decidirToggleEq(activoActual: false, eqDisponible: true).nuevoActivo, isTrue, ); }); test('a native call is required only when the effect is attached', () { expect( decidirToggleEq( activoActual: true, eqDisponible: true, ).requiereLlamadaNativa, isTrue, ); expect( decidirToggleEq( activoActual: true, eqDisponible: false, ).requiereLlamadaNativa, isFalse, reason: 'with no native Equalizer effect the flag still flips, but ' 'nothing is pushed to the platform', ); }); test('the flag still flips with no native effect — the car button must ' 'never look inert', () { expect( decidirToggleEq(activoActual: false, eqDisponible: false).nuevoActivo, isTrue, ); }); }); group('customAction dispatch (C — zero coverage before this)', () { test('the accionEqToggle literal routes through decidirToggleEq', () async { final handler = crearHandler(); registrarHandler(handler); await handler.setEcualizadorActivo(true); await handler.customAction(accionEqToggle); expect(handler.ecualizadorActivo, isFalse); await handler.customAction(accionEqToggle); expect(handler.ecualizadorActivo, isTrue); }); test('a car toggle persists through the same write port as a phone ' 'toggle', () async { final handler = crearHandler(); final escrituras = []; registrarHandler( handler, guardarEqActivoPersistido: (activo) async => escrituras.add(activo), ); // Explicit starting state. A fresh handler seeds `_ecualizadorActivo` // from the module-level `_eqActivoPersistido` cache, which any earlier // test in this file leaves at whatever it last wrote. This assertion // is about what the TOGGLE does, not about what the previous test // happened to leave behind — without these two lines, simply // reordering the tests silently flips the expectation to `[true]`. await handler.setEcualizadorActivo(true); escrituras.clear(); await handler.customAction(accionEqToggle); expect(escrituras, [false]); }); test('an unknown custom action is a silent no-op', () async { final handler = crearHandler(); registrarHandler(handler); final antes = handler.ecualizadorActivo; await handler.customAction('accion.inexistente'); expect(handler.ecualizadorActivo, antes); }); }); group('debeReasertarEcualizadorNativo (D — re-assert on activation)', () { test('an idle -> active transition with the effect attached re-asserts', () { expect( PluriWaveAudioHandler.debeReasertarEcualizadorNativo( estado: ProcessingState.ready, reproductorActivoAntes: false, eqDisponible: true, ), isTrue, reason: "just_audio's AudioEffect.setEnabled only reaches the platform " 'while the player is active, so a toggle made while stopped ' 'never landed natively', ); }); test('staying active does not re-assert on every event', () { expect( PluriWaveAudioHandler.debeReasertarEcualizadorNativo( estado: ProcessingState.ready, reproductorActivoAntes: true, eqDisponible: true, ), isFalse, ); }); test('going idle does not re-assert', () { expect( PluriWaveAudioHandler.debeReasertarEcualizadorNativo( estado: ProcessingState.idle, reproductorActivoAntes: true, eqDisponible: true, ), isFalse, ); }); test('no attached effect never re-asserts', () { expect( PluriWaveAudioHandler.debeReasertarEcualizadorNativo( estado: ProcessingState.ready, reproductorActivoAntes: false, eqDisponible: false, ), isFalse, ); }); test('buffering/loading/completed already count as active', () { for (final estado in [ ProcessingState.loading, ProcessingState.buffering, ProcessingState.completed, ]) { expect( PluriWaveAudioHandler.debeReasertarEcualizadorNativo( estado: estado, reproductorActivoAntes: false, eqDisponible: true, ), isTrue, reason: '$estado is a non-idle state, i.e. the platform player is ' 'attached and accepts effect calls', ); } }); }); /// D, WIRING half. Everything above this group tests the pure /// [PluriWaveAudioHandler.debeReasertarEcualizadorNativo] predicate and /// nothing else: deleting the `playerStateStream` listener's whole re-assert /// block — the `if (debeReasertar...) unawaited(_reasertarEcualizadorNativo())` /// call, the `_reproductorActivo = proc != ProcessingState.idle` edge /// tracking — left the suite green. That is the SAME producer-only hole that /// let a dead Android Auto EQ button ship, so it gets closed here rather than /// re-tested at the predicate. /// /// [PluriWaveAudioHandler.manejarEstadoPlayer] IS the listener body — the /// same method `playerStateStream.listen` is subscribed to — so these /// drive the real handler through real player-state transitions. group('D (wiring) — the playerState idle -> active edge', () { PlayerState estado(ProcessingState proc, {bool playing = false}) => PlayerState(playing, proc); test('the first non-idle event re-asserts the native effect exactly ' 'once, and staying active never re-asserts again', () async { final handler = crearHandler(); registrarHandler(handler); handler.simularEcualizadorDisponible(true); expect(handler.reasercionesEcualizador, 0); handler.manejarEstadoPlayer(estado(ProcessingState.loading)); expect( handler.reasercionesEcualizador, 1, reason: "just_audio's AudioEffect.setEnabled is a no-op while the " 'platform player is detached, so a toggle made while stopped ' 'only lands on this edge', ); handler.manejarEstadoPlayer(estado(ProcessingState.buffering)); handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); handler.manejarEstadoPlayer(estado(ProcessingState.completed)); expect( handler.reasercionesEcualizador, 1, reason: 'the player emits many events while active; re-asserting on ' 'each one would be a native call storm', ); }); test('going idle re-arms the edge, so stop + play re-asserts again — ' 'this is the `_reproductorActivo = proc != idle` line', () async { final handler = crearHandler(); registrarHandler(handler); handler.simularEcualizadorDisponible(true); handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); expect(handler.reasercionesEcualizador, 1); handler.manejarEstadoPlayer(estado(ProcessingState.idle)); expect( handler.reasercionesEcualizador, 1, reason: 'going idle itself never re-asserts', ); handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); expect( handler.reasercionesEcualizador, 2, reason: 'without the edge-tracking assignment the flag would stay true ' 'and the toggle made while stopped would never land natively', ); }); test('with no native effect attached nothing is ever re-asserted', () async { final handler = crearHandler(); registrarHandler(handler); // `_eqDisponible` is false off-device, which is also the real // "device has no Equalizer effect" case. handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); handler.manejarEstadoPlayer(estado(ProcessingState.idle)); handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); expect(handler.reasercionesEcualizador, 0); }); }); group('F — the EQ re-push must not rewind the car progress bar', () { test('the EQ controls re-push refreshes updatePosition from the ' 'player', () async { final handler = crearHandler(); registrarHandler(handler); handler.playbackState.add( handler.playbackState.value.copyWith( updatePosition: const Duration(minutes: 3), ), ); await handler.setEcualizadorActivo(false); expect( handler.playbackState.value.updatePosition, handler.posicionActual, reason: 'copyWith stamps a fresh updateTime but keeps the OLD ' 'updatePosition, so an EQ tap told the car "you are at 3:00, as ' 'of right now" and the bar snapped backwards', ); }); }); }