Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on stop button makes the entire app useless", citado contra las Android for Cars App Quality Guidelines. La causa no era el boton de parar. Maquina de estados del transporte _cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de fuente se encolan incrementando la revision al encolar, no al ejecutar, asi que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que las entradas obsoletas reescribieran loading sobre el idle que stop() acababa de publicar. Estado final: loading para siempre sobre una sesion que audio_service ya habia desactivado. Ahora la guarda de revision es la primera sentencia del metodo. pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual despues de pulsar pausa; se revalida la intencion antes de llamar a play(). Se anade un suelo de estado que cierra cualquier loading o buffering sin carga viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando, para no convertir un tunel en un error. El presupuesto hasta el primer mensaje baja a menos de diez segundos y los reintentos ya no borran el mensaje visible. Tier gratuito en el coche El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando no habia premium, y un revisor con instalacion limpia siempre es tier gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con emisoras reales y reproducibles, compiladas en el binario para que existan en frio, y la puerta de entitlement acota contenido en vez de bloquear acciones. Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la ultima emisora, que fallaba tambien a los clientes de pago. Localizacion El locale del handler solo lo fijaba un widget que el motor headless nunca construye, asi que todo error del coche salia en castellano. Se resuelve desde el locale de plataforma. Se traducen las once etiquetas del arbol que estaban a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si vuelve a aparecer texto visible fuera del sistema de traduccion. Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los 5 avisos preexistentes.
479 lines
17 KiB
Dart
479 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';
|
|
|
|
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<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 = crearHandler();
|
|
|
|
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 = crearHandler();
|
|
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 = 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<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 = 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 = <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 = crearHandler();
|
|
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 = 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 = <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 = 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',
|
|
);
|
|
});
|
|
});
|
|
}
|