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.
680 lines
23 KiB
Dart
680 lines
23 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' show Locale;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
|
import 'package:pluriwave/modelos/pista_local.dart';
|
|
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
|
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
|
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../helpers/handlers_audio.dart';
|
|
|
|
/// Free-tier Android Auto surface, handler side (fix/auto-quality-guidelines,
|
|
/// items 9, 11, 12, 13, 14).
|
|
///
|
|
/// Every test here runs with `_fuenteNavegacionGlobal` NEVER registered —
|
|
/// this file never calls `registrarFuenteNavegacion`. That is the exact bind
|
|
/// a Play reviewer performs: Android Auto starts the headless engine, and
|
|
/// until (and even after) `main.dart` wires its sources, the car's browse and
|
|
/// play paths must produce real, playable content out of the binary alone.
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
final crearHandler = registrarHandlersLiberables();
|
|
|
|
late _GuionReproductor guion;
|
|
|
|
setUp(() {
|
|
guion = _GuionReproductor();
|
|
PluriWaveAudioHandler.fabricaReproductorPrueba =
|
|
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
|
|
// Fresh install = free tier: `esPremiumPersistido` is
|
|
// `getBool('compra_premium_v1') ?? false`, and there is no trial key.
|
|
SharedPreferences.setMockInitialValues({});
|
|
});
|
|
|
|
tearDown(() {
|
|
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
|
|
PluriWaveAudioHandler.lectorLocalePlataforma =
|
|
PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto;
|
|
});
|
|
|
|
AppLocalizations textos() => lookupAppLocalizations(const Locale('es'));
|
|
|
|
group('debeBloquearCambioDeEmisora — item 11', () {
|
|
test('free + emisora gratuita: NO bloquea', () {
|
|
expect(
|
|
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('free + emisora premium: bloquea (propiedad de seguridad original — '
|
|
'un emisora:<uuid> rancio de antes de una bajada de tier no puede '
|
|
'sonar)', () {
|
|
expect(
|
|
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('premium: nunca bloquea', () {
|
|
expect(
|
|
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: true),
|
|
isFalse,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('getChildren — item 9', () {
|
|
test('sin fuente de navegación registrada, la carpeta gratuita devuelve '
|
|
'>= 3 items REPRODUCIBLES', () async {
|
|
final handler = crearHandler();
|
|
|
|
final items = await handler.getChildren(
|
|
ConstructorArbolAuto.idDestacadas,
|
|
);
|
|
|
|
expect(items.length, greaterThanOrEqualTo(3));
|
|
expect(items.every((m) => m.playable == true), isTrue);
|
|
expect(items.every((m) => m.id.startsWith('emisora:')), isTrue);
|
|
});
|
|
|
|
test('la raíz gratuita es UNA carpeta y no paga el round trip nativo de '
|
|
'estadoCarpeta()', () async {
|
|
final fuenteLocal = _FuenteMusicaLocalEspia();
|
|
registrarFuenteMusicaLocal(fuenteLocal);
|
|
final handler = crearHandler();
|
|
|
|
final raiz = await handler.getChildren(AudioService.browsableRootId);
|
|
|
|
expect(raiz.map((m) => m.id), [ConstructorArbolAuto.idDestacadas]);
|
|
expect(
|
|
fuenteLocal.llamadasEstadoCarpeta,
|
|
0,
|
|
reason:
|
|
'estadoCarpeta() viaja por un MethodChannel que NO existe en el '
|
|
'motor headless; el tier gratuito no puede ver Música Local, así '
|
|
'que preguntarlo solo añade una vía de fallo en la raíz',
|
|
);
|
|
});
|
|
|
|
test('una carpeta premium NO devuelve una fila no reproducible: devuelve '
|
|
'el contenido gratuito', () async {
|
|
final handler = crearHandler();
|
|
|
|
final items = await handler.getChildren(ConstructorArbolAuto.idTodas);
|
|
|
|
expect(items, isNotEmpty);
|
|
expect(items.every((m) => m.playable == true), isTrue);
|
|
expect(items.every((m) => m.id != 'premium:info'), isTrue);
|
|
});
|
|
});
|
|
|
|
group('playFromMediaId — item 12', () {
|
|
test('free + uuid gratuito: SUENA', () async {
|
|
final handler = crearHandler();
|
|
final destacada = emisorasDestacadas.first;
|
|
|
|
await handler.playFromMediaId('emisora:${destacada.uuid}');
|
|
await pumpEventQueue();
|
|
|
|
expect(guion.urlsSolicitadas, contains(destacada.url));
|
|
});
|
|
|
|
test('free + uuid premium: publica error CON errorCode y errorMessage '
|
|
'localizado, nunca vuelve en silencio', () async {
|
|
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromMediaId('emisora:uuid-del-catalogo');
|
|
await pumpEventQueue();
|
|
|
|
final estado = handler.playbackState.value;
|
|
expect(estado.processingState, AudioProcessingState.error);
|
|
expect(estado.errorCode, isNotNull);
|
|
expect(
|
|
estado.errorMessage,
|
|
lookupAppLocalizations(const Locale('en')).autoErrorEmisoraPremium,
|
|
);
|
|
expect(guion.urlsSolicitadas, isEmpty);
|
|
});
|
|
|
|
test('free + pista local: bloqueada, y también publica el error', () async {
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromMediaId('pista:doc-id');
|
|
await pumpEventQueue();
|
|
|
|
expect(
|
|
handler.playbackState.value.processingState,
|
|
AudioProcessingState.error,
|
|
);
|
|
});
|
|
|
|
test('free + uuid inexistente en NINGÚN sitio: error, no silencio',
|
|
() async {
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromMediaId('emisora:');
|
|
await pumpEventQueue();
|
|
|
|
expect(
|
|
handler.playbackState.value.processingState,
|
|
AudioProcessingState.error,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('playFromSearch — items 12 y 13', () {
|
|
test('free: una consulta que casa con el set gratuito SUENA', () async {
|
|
final handler = crearHandler();
|
|
final destacada = emisorasDestacadas.first;
|
|
|
|
await handler.playFromSearch(destacada.nombre);
|
|
await pumpEventQueue();
|
|
|
|
expect(guion.urlsSolicitadas, contains(destacada.url));
|
|
});
|
|
|
|
test('free: una consulta sin resultados publica error localizado, nunca '
|
|
'vuelve en silencio', () async {
|
|
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromSearch('emisora que no existe en ningun sitio');
|
|
await pumpEventQueue();
|
|
|
|
final estado = handler.playbackState.value;
|
|
expect(estado.processingState, AudioProcessingState.error);
|
|
expect(estado.errorCode, isNotNull);
|
|
expect(estado.errorMessage, textos().autoErrorBusquedaSinResultados);
|
|
});
|
|
|
|
test('consulta VACÍA ("Reproduce PluriWave") arranca algo — free', () async {
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromSearch('');
|
|
await pumpEventQueue();
|
|
|
|
expect(guion.urlsSolicitadas, isNotEmpty);
|
|
});
|
|
|
|
test('consulta VACÍA arranca algo — PREMIUM (antes fallaba incluso para '
|
|
'un cliente que había pagado)', () async {
|
|
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromSearch(' ');
|
|
await pumpEventQueue();
|
|
|
|
expect(guion.urlsSolicitadas, isNotEmpty);
|
|
});
|
|
|
|
test('consulta vacía prefiere la ÚLTIMA escuchada', () async {
|
|
SharedPreferences.setMockInitialValues({
|
|
claveUltimaEmisora:
|
|
'{"uuid":"uuid-ultima","nombre":"Ultima",'
|
|
'"url":"https://ultima.example/stream"}',
|
|
});
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromSearch('');
|
|
await pumpEventQueue();
|
|
|
|
expect(guion.urlsSolicitadas, ['https://ultima.example/stream']);
|
|
});
|
|
});
|
|
|
|
group('un rechazo NO destruye una sesion que esta sonando — hallazgo 1', () {
|
|
/// Puts the handler in the exact state the reviewer reproduces: a free
|
|
/// station tapped from the browse tree and audibly playing.
|
|
Future<PluriWaveAudioHandler> sonando() async {
|
|
final handler = crearHandler();
|
|
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
|
await pumpEventQueue();
|
|
guion.ultimoReproductor!.emitir(
|
|
PlayerState(true, ProcessingState.ready),
|
|
);
|
|
await pumpEventQueue();
|
|
expect(
|
|
handler.playbackState.value.playing,
|
|
isTrue,
|
|
reason: 'precondicion: la emisora esta sonando',
|
|
);
|
|
return handler;
|
|
}
|
|
|
|
test(
|
|
'una busqueda por voz fallida deja el estado publicado FUERA de error y '
|
|
'sigue ofreciendo play/pause/stop',
|
|
() async {
|
|
final handler = await sonando();
|
|
|
|
// The free candidate set is only the six compiled-in stations, so
|
|
// almost any spoken station name misses. That must not cost the
|
|
// driver the whole now-playing screen.
|
|
await handler.playFromSearch('BBC');
|
|
await pumpEventQueue();
|
|
|
|
final estado = handler.playbackState.value;
|
|
expect(
|
|
estado.processingState,
|
|
isNot(AudioProcessingState.error),
|
|
reason:
|
|
'AudioService.java:601-611 maps `error` to STATE_ERROR with the '
|
|
'`playing` flag IGNORED, so publishing it over live audio '
|
|
'replaces the transport row with an error the session can never '
|
|
'clear: playerStateStream is .distinct() (nothing more comes '
|
|
'from a steadily playing ExoPlayer) and _bufferedSub re-asserts '
|
|
'it ~2x/second through copyWith',
|
|
);
|
|
expect(
|
|
estado.playing,
|
|
isTrue,
|
|
reason: 'el audio sigue sonando; el estado tiene que decirlo',
|
|
);
|
|
expect(
|
|
estado.systemActions,
|
|
containsAll(<MediaAction>[
|
|
MediaAction.play,
|
|
MediaAction.pause,
|
|
MediaAction.stop,
|
|
]),
|
|
);
|
|
expect(
|
|
estado.controls.map((c) => c.action),
|
|
containsAll(<MediaAction>[MediaAction.pause, MediaAction.stop]),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('y el motivo del fallo SIGUE llegando al head unit', () async {
|
|
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
|
|
final handler = await sonando();
|
|
|
|
await handler.playFromSearch('BBC');
|
|
await pumpEventQueue();
|
|
|
|
// AudioService.java:541-544 calls `setErrorMessage` from `setState`
|
|
// regardless of processingState, so the text still reaches
|
|
// PlaybackStateCompat without STATE_ERROR.
|
|
expect(
|
|
handler.playbackState.value.errorMessage,
|
|
textos().autoErrorBusquedaSinResultados,
|
|
);
|
|
expect(handler.playbackState.value.errorCode, isNotNull);
|
|
});
|
|
|
|
test(
|
|
'sin sesion viva el rechazo SI es terminal: error explicado (el caso en '
|
|
'que el coche no tiene nada que perder)',
|
|
() async {
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromSearch('BBC');
|
|
await pumpEventQueue();
|
|
|
|
expect(
|
|
handler.playbackState.value.processingState,
|
|
AudioProcessingState.error,
|
|
);
|
|
expect(handler.playbackState.value.errorMessage, isNotNull);
|
|
},
|
|
);
|
|
|
|
/// CONTRACT for an action refusal published over a LIVE session
|
|
/// (`_publicarErrorAuto`'s non-terminal branch):
|
|
///
|
|
/// 1. the code and the message are published immediately and stand for
|
|
/// [PluriWaveAudioHandler.ventanaErrorAccionAuto] (the two tests
|
|
/// above pin step 1);
|
|
/// 2. when that window elapses they are BOTH cleared, and nothing else
|
|
/// about the state moves;
|
|
/// 3. any real player transition arriving first clears them early — a
|
|
/// genuine state change supersedes a stale refusal;
|
|
/// 4. only the fields this refusal published are ever cleared, so a
|
|
/// reconnect status message that replaced them survives.
|
|
///
|
|
/// It has to be bounded: `_bufferedSub` republishes
|
|
/// `playbackState.value.copyWith(...)` ~2x/second and `copyWith` carries
|
|
/// every omitted field forward (audio_service.dart:400-427), so
|
|
/// `AudioService.java:541-544` re-calls `setErrorMessage(code, msg)` on
|
|
/// every one of those pushes. Without a clear, one voice miss makes the
|
|
/// session advertise an error for the rest of the station's playback,
|
|
/// over audible healthy audio.
|
|
group('y el rechazo es TRANSITORIO: nada lo arrastra para siempre', () {
|
|
setUp(() {
|
|
PluriWaveAudioHandler.ventanaErrorAccionAuto = const Duration(
|
|
milliseconds: 80,
|
|
);
|
|
});
|
|
|
|
tearDown(() {
|
|
PluriWaveAudioHandler.ventanaErrorAccionAuto =
|
|
PluriWaveAudioHandler.ventanaErrorAccionAutoPorDefecto;
|
|
});
|
|
|
|
test('al pasar la ventana, codigo y mensaje desaparecen', () async {
|
|
final handler = await sonando();
|
|
|
|
await handler.playFromSearch('BBC');
|
|
await pumpEventQueue();
|
|
expect(
|
|
handler.playbackState.value.errorCode,
|
|
isNotNull,
|
|
reason: 'precondicion: el rechazo se publico',
|
|
);
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
|
|
|
final estado = handler.playbackState.value;
|
|
expect(
|
|
estado.errorCode,
|
|
isNull,
|
|
reason:
|
|
'every later push carries the code forward through copyWith, so '
|
|
'the session would keep telling the head unit it is in error '
|
|
'while the station plays perfectly',
|
|
);
|
|
expect(estado.errorMessage, isNull);
|
|
expect(
|
|
estado.playing,
|
|
isTrue,
|
|
reason: 'clearing the refusal must not touch the session itself',
|
|
);
|
|
expect(
|
|
estado.processingState,
|
|
isNot(AudioProcessingState.error),
|
|
reason: 'nor its processing state',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'y un cambio de estado real del reproductor los limpia antes',
|
|
() async {
|
|
final handler = await sonando();
|
|
|
|
await handler.playFromSearch('BBC');
|
|
await pumpEventQueue();
|
|
expect(handler.playbackState.value.errorMessage, isNotNull);
|
|
|
|
// The driver pauses: a genuine transition. `manejarEstadoPlayer`
|
|
// omitted both fields, so `copyWith` carried the refusal into the
|
|
// paused state and every state after it.
|
|
handler.manejarEstadoPlayer(
|
|
PlayerState(false, ProcessingState.ready),
|
|
);
|
|
await pumpEventQueue();
|
|
|
|
final estado = handler.playbackState.value;
|
|
expect(estado.errorCode, isNull);
|
|
expect(estado.errorMessage, isNull);
|
|
},
|
|
);
|
|
|
|
test('y stop() limpia el codigo, no solo el mensaje', () async {
|
|
final handler = await sonando();
|
|
|
|
await handler.playFromSearch('BBC');
|
|
await pumpEventQueue();
|
|
expect(handler.playbackState.value.errorCode, isNotNull);
|
|
|
|
await handler.stop();
|
|
await pumpEventQueue();
|
|
|
|
final estado = handler.playbackState.value;
|
|
expect(
|
|
estado.errorCode,
|
|
isNull,
|
|
reason:
|
|
'stop() cleared errorMessage but omitted errorCode, so the idle '
|
|
'it publishes shipped a stale ERROR_CODE_PREMIUM_ACCOUNT_'
|
|
'REQUIRED (4) with no message to explain it',
|
|
);
|
|
expect(estado.errorMessage, isNull);
|
|
});
|
|
});
|
|
});
|
|
|
|
group('errorCode se limpia en un cambio de fuente — item 6', () {
|
|
test('un rechazo premium seguido de una emisora gratuita NO arrastra el '
|
|
'codigo de error', () async {
|
|
final handler = crearHandler();
|
|
|
|
await handler.playFromMediaId('emisora:uuid-del-catalogo');
|
|
await pumpEventQueue();
|
|
expect(
|
|
handler.playbackState.value.errorCode,
|
|
isNotNull,
|
|
reason: 'precondicion: el rechazo publico un codigo',
|
|
);
|
|
|
|
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
|
await pumpEventQueue();
|
|
|
|
expect(
|
|
handler.playbackState.value.errorCode,
|
|
isNull,
|
|
reason:
|
|
'`_cambiarFuente` cleared errorMessage but omitted errorCode, and '
|
|
'copyWith carries an omitted field forward, so a stale code rode '
|
|
'along indefinitely',
|
|
);
|
|
expect(handler.playbackState.value.errorMessage, isNull);
|
|
});
|
|
});
|
|
|
|
group('getChildren(recentRootId) — reanudacion del head unit', () {
|
|
test('con una ultima emisora persistida devuelve EXACTAMENTE un item '
|
|
'reproducible', () async {
|
|
SharedPreferences.setMockInitialValues({
|
|
claveUltimaEmisora:
|
|
'{"uuid":"uuid-ultima","nombre":"Ultima",'
|
|
'"url":"https://ultima.example/stream"}',
|
|
});
|
|
final handler = crearHandler();
|
|
|
|
final items = await handler.getChildren(AudioService.recentRootId);
|
|
|
|
expect(
|
|
items,
|
|
hasLength(1),
|
|
reason:
|
|
'onGetRoot (AudioService.java:817-821) answers "recent" whenever '
|
|
'the head unit sends EXTRA_RECENT, which Android Auto does on '
|
|
'reconnect, and the platform expects exactly one resume item — '
|
|
'free tier used to fall through and return all six stations',
|
|
);
|
|
expect(items.single.playable, isTrue);
|
|
expect(items.single.id, 'emisora:uuid-ultima');
|
|
});
|
|
|
|
test('sin ultima emisora devuelve lista vacia y no lanza', () async {
|
|
final handler = crearHandler();
|
|
|
|
expect(await handler.getChildren(AudioService.recentRootId), isEmpty);
|
|
});
|
|
|
|
test('premium tambien obtiene su tile de reanudacion, no una lista '
|
|
'vacia', () async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'compra_premium_v1': true,
|
|
claveUltimaEmisora:
|
|
'{"uuid":"uuid-premium","nombre":"Premium",'
|
|
'"url":"https://premium.example/stream"}',
|
|
});
|
|
final handler = crearHandler();
|
|
|
|
final items = await handler.getChildren(AudioService.recentRootId);
|
|
|
|
expect(items, hasLength(1));
|
|
expect(items.single.id, 'emisora:uuid-premium');
|
|
});
|
|
});
|
|
|
|
group('la raiz gratuita esta LOCALIZADA — hallazgo 4', () {
|
|
test('en un motor headless en ingles la unica carpeta que un revisor de '
|
|
'Play ve NO sale en castellano', () async {
|
|
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
|
|
final handler = crearHandler();
|
|
|
|
final raiz = await handler.getChildren(AudioService.browsableRootId);
|
|
|
|
expect(raiz, hasLength(1));
|
|
expect(
|
|
raiz.single.title,
|
|
lookupAppLocalizations(const Locale('en')).autoCarpetaEscuchar,
|
|
reason:
|
|
'raiz(premium: false) is 100% of the browse tree a Play reviewer '
|
|
'ever sees, on a device in any of the 13 shipped locales — the '
|
|
'hardcoded-Spanish car-label convention stops being defensible '
|
|
'once one label IS the whole free root',
|
|
);
|
|
expect(raiz.single.title, isNot('Escuchar'));
|
|
});
|
|
|
|
test('en castellano sigue diciendo Escuchar', () async {
|
|
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
|
|
final handler = crearHandler();
|
|
|
|
final raiz = await handler.getChildren(AudioService.browsableRootId);
|
|
|
|
expect(raiz.single.title, 'Escuchar');
|
|
});
|
|
});
|
|
|
|
group('botones de salto — item 14', () {
|
|
test('free: el salto CICLA dentro del set gratuito en vez de no hacer '
|
|
'nada', () async {
|
|
final handler = crearHandler();
|
|
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
|
await pumpEventQueue();
|
|
|
|
await handler.skipToNext();
|
|
await pumpEventQueue();
|
|
|
|
expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url);
|
|
});
|
|
|
|
test('free: el salto hacia atrás envuelve al final del set', () async {
|
|
final handler = crearHandler();
|
|
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
|
await pumpEventQueue();
|
|
|
|
await handler.skipToPrevious();
|
|
await pumpEventQueue();
|
|
|
|
expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url);
|
|
});
|
|
|
|
test('free: los botones se SIGUEN anunciando (un boton que funciona es '
|
|
'mejor UX que un hueco, y controls/systemActions se construyen en un '
|
|
'listener sincrono que no puede await-ear prefs)', () async {
|
|
final handler = crearHandler();
|
|
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
|
await pumpEventQueue();
|
|
// `controls`/`systemActions` are only rebuilt from a real player event,
|
|
// so the double has to emit one for this assertion to mean anything.
|
|
guion.ultimoReproductor!.emitir(
|
|
PlayerState(true, ProcessingState.ready),
|
|
);
|
|
await pumpEventQueue();
|
|
|
|
final estado = handler.playbackState.value;
|
|
expect(estado.systemActions, contains(MediaAction.skipToNext));
|
|
expect(estado.systemActions, contains(MediaAction.skipToPrevious));
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Cuenta los round trips nativos que la raíz del árbol dispara.
|
|
class _FuenteMusicaLocalEspia implements FuenteMusicaLocalAuto {
|
|
int llamadasEstadoCarpeta = 0;
|
|
|
|
@override
|
|
Future<EstadoCarpetaLocal> estadoCarpeta() async {
|
|
llamadasEstadoCarpeta++;
|
|
return EstadoCarpetaLocal.configurada;
|
|
}
|
|
|
|
@override
|
|
Future<List<NodoLocal>> hijos(String documentId) async => const [];
|
|
|
|
@override
|
|
Future<String?> uriContenidoDePista(String documentId) async => null;
|
|
|
|
@override
|
|
Future<Map<String, MetadatosPista>> metadatosDe(
|
|
List<String> documentIds,
|
|
) async => const {};
|
|
}
|
|
|
|
/// Misma forma que el doble de `servicio_audio_transporte_test.dart`.
|
|
class _GuionReproductor {
|
|
int llamadasPlay = 0;
|
|
final urlsSolicitadas = <String>[];
|
|
|
|
/// The handler rebuilds its player on every source change, so a test that
|
|
/// needs to drive player events has to reach the LATEST instance.
|
|
_ReproductorFalso? ultimoReproductor;
|
|
}
|
|
|
|
class _ReproductorFalso extends AudioPlayer {
|
|
_ReproductorFalso(
|
|
this._guion,
|
|
AudioPipeline pipeline,
|
|
AudioLoadConfiguration carga,
|
|
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
|
|
_guion.ultimoReproductor = this;
|
|
}
|
|
|
|
final _GuionReproductor _guion;
|
|
final _estados = StreamController<PlayerState>.broadcast();
|
|
|
|
void emitir(PlayerState estado) => _estados.add(estado);
|
|
|
|
@override
|
|
Stream<PlayerState> get playerStateStream => _estados.stream;
|
|
|
|
@override
|
|
Future<Duration?> setUrl(
|
|
String url, {
|
|
Map<String, String>? headers,
|
|
Duration? initialPosition,
|
|
bool preload = true,
|
|
dynamic tag,
|
|
}) async {
|
|
_guion.urlsSolicitadas.add(url);
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<void> play() async {
|
|
_guion.llamadasPlay++;
|
|
}
|
|
|
|
@override
|
|
Future<void> pause() async {}
|
|
|
|
@override
|
|
Future<void> stop() async {}
|
|
|
|
@override
|
|
Future<void> setVolume(double volume) async {}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
await _estados.close();
|
|
}
|
|
}
|