Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.
1. Ecualizador: el estado no tenia dueño unico
El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.
Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.
Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.
El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.
2. Musica Local no aparecia en el arbol de Android Auto
hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.
La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.
EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.
3. El paywall bloqueaba las compras
restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.
Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
475 lines
17 KiB
Dart
475 lines
17 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState;
|
|
import 'package:pluriwave/servicios/servicio_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();
|
|
|
|
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 = PluriWaveAudioHandler();
|
|
var lecturas = 0;
|
|
|
|
registrarHandler(
|
|
handler,
|
|
leerEqActivoPersistido: () async {
|
|
lecturas++;
|
|
return false;
|
|
},
|
|
);
|
|
await Future<void>.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 = PluriWaveAudioHandler();
|
|
|
|
registrarHandler(
|
|
handler,
|
|
leerEqActivoPersistido: () async => throw StateError('sin disco'),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(handler.ecualizadorActivo, isTrue);
|
|
});
|
|
|
|
test('without a read port the handler is left untouched (widget tests, '
|
|
'fakes)', () async {
|
|
final handler = PluriWaveAudioHandler();
|
|
await handler.setEcualizadorActivo(false);
|
|
|
|
registrarHandler(handler);
|
|
await Future<void>.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 = PluriWaveAudioHandler();
|
|
|
|
// 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<void>.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 = PluriWaveAudioHandler();
|
|
|
|
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 = PluriWaveAudioHandler();
|
|
registrarHandler(handler);
|
|
|
|
await handler.setEcualizadorActivo(false);
|
|
expect(
|
|
PluriWaveAudioHandler().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(PluriWaveAudioHandler().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 = PluriWaveAudioHandler();
|
|
final escrituras = <bool>[];
|
|
|
|
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 = PluriWaveAudioHandler();
|
|
final escrituras = <bool>[];
|
|
|
|
registrarHandler(
|
|
handler,
|
|
leerEqActivoPersistido: () async => false,
|
|
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(handler.ecualizadorActivo, isFalse);
|
|
expect(escrituras, isEmpty);
|
|
});
|
|
|
|
test('a failing write port never breaks the toggle', () async {
|
|
final handler = PluriWaveAudioHandler();
|
|
|
|
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 = PluriWaveAudioHandler();
|
|
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 = PluriWaveAudioHandler();
|
|
final escrituras = <bool>[];
|
|
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 = PluriWaveAudioHandler();
|
|
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 = PluriWaveAudioHandler();
|
|
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 = PluriWaveAudioHandler();
|
|
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 = PluriWaveAudioHandler();
|
|
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 = PluriWaveAudioHandler();
|
|
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',
|
|
);
|
|
});
|
|
});
|
|
}
|