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.
268 lines
9.3 KiB
Dart
268 lines
9.3 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/modelos/emisora.dart';
|
|
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
|
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// Free-tier Android Auto surface (fix/auto-quality-guidelines, items 7, 8
|
|
/// and 10).
|
|
///
|
|
/// Google Play returned "Approved with Issues" against the Android for Cars
|
|
/// App Quality Guidelines on version code 157. The free root advertised four
|
|
/// folders that each dead-ended on a single non-playable "Función Premium"
|
|
/// row, and on a cold headless bind every one of the underlying lists is
|
|
/// empty anyway. This suite pins the replacement: ONE browsable folder that
|
|
/// resolves to real, playable stations.
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('FuenteEmisorasAutoLocal.porUuid — item 7', () {
|
|
// Cold bind shape: `todas()` is `_snapshotTodas ?? const []`, no
|
|
// favourites (sqflite is not initialised under `flutter test`, so the
|
|
// read throws and degrades to `[]`), and a custom-stations path that
|
|
// does not exist.
|
|
FuenteEmisorasAutoLocal fuenteFria() => FuenteEmisorasAutoLocal(
|
|
resolverRutaCustom: () async => 'no/existe/emisoras_custom.json',
|
|
);
|
|
|
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
|
|
|
test('en frío resuelve un uuid destacado (antes devolvía null y la fila '
|
|
'no hacía nada al tocarla)', () async {
|
|
final fuente = fuenteFria();
|
|
|
|
final resuelta = await fuente.porUuid(emisorasDestacadas.first.uuid);
|
|
|
|
expect(resuelta, isNotNull);
|
|
expect(resuelta!.url, emisorasDestacadas.first.url);
|
|
});
|
|
|
|
test('en frío resuelve la última escuchada persistida', () async {
|
|
const ultima = Emisora(
|
|
uuid: 'uuid-ultima',
|
|
nombre: 'Ultima',
|
|
url: 'https://ultima.example/stream',
|
|
);
|
|
SharedPreferences.setMockInitialValues({
|
|
claveUltimaEmisora:
|
|
'{"uuid":"uuid-ultima","nombre":"Ultima",'
|
|
'"url":"https://ultima.example/stream"}',
|
|
});
|
|
|
|
final resuelta = await fuenteFria().porUuid(ultima.uuid);
|
|
|
|
expect(resuelta?.url, ultima.url);
|
|
});
|
|
|
|
test('un uuid desconocido sigue devolviendo null', () async {
|
|
expect(await fuenteFria().porUuid('uuid-inexistente'), isNull);
|
|
});
|
|
|
|
test('el snapshot vivo gana al set destacado para el MISMO uuid',
|
|
() async {
|
|
final fuente = fuenteFria();
|
|
final delCatalogo = Emisora(
|
|
uuid: emisorasDestacadas.first.uuid,
|
|
nombre: 'Version viva',
|
|
url: 'https://viva.example/stream',
|
|
);
|
|
fuente.actualizarSnapshot(todas: [delCatalogo]);
|
|
|
|
final resuelta = await fuente.porUuid(delCatalogo.uuid);
|
|
|
|
expect(resuelta?.url, 'https://viva.example/stream');
|
|
});
|
|
});
|
|
|
|
group('raiz(premium:) — item 8', () {
|
|
test('free: exactamente UNA carpeta navegable, y ninguna de las cuatro '
|
|
'que morían en la fila premium', () {
|
|
final constructor = ConstructorArbolAuto();
|
|
|
|
// `incluirMusicaLocal: true` a propósito: ni siquiera con carpeta
|
|
// local configurada puede el tier gratuito ver ese nodo.
|
|
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
|
|
|
|
expect(libre, hasLength(1));
|
|
expect(libre.single.id, ConstructorArbolAuto.idDestacadas);
|
|
expect(libre.single.playable, isFalse);
|
|
expect(libre.single.title, isNotEmpty);
|
|
expect(
|
|
libre.map((m) => m.id),
|
|
isNot(
|
|
anyOf(
|
|
contains(ConstructorArbolAuto.idFavoritos),
|
|
contains(ConstructorArbolAuto.idTodas),
|
|
contains(ConstructorArbolAuto.idMisEmisoras),
|
|
contains(ConstructorArbolAuto.idMusicaLocal),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('premium: el árbol de hoy, sin cambios (guardia de regresión)', () {
|
|
final constructor = ConstructorArbolAuto();
|
|
|
|
expect(
|
|
constructor
|
|
.raiz(incluirMusicaLocal: true, premium: true)
|
|
.map((m) => m.id),
|
|
[
|
|
ConstructorArbolAuto.idFavoritos,
|
|
ConstructorArbolAuto.idTodas,
|
|
ConstructorArbolAuto.idMisEmisoras,
|
|
ConstructorArbolAuto.idMusicaLocal,
|
|
],
|
|
);
|
|
expect(
|
|
constructor
|
|
.raiz(incluirMusicaLocal: false, premium: true)
|
|
.map((m) => m.id),
|
|
[
|
|
ConstructorArbolAuto.idFavoritos,
|
|
ConstructorArbolAuto.idTodas,
|
|
ConstructorArbolAuto.idMisEmisoras,
|
|
],
|
|
);
|
|
expect(
|
|
constructor
|
|
.raiz(incluirMusicaLocal: true, premium: true)
|
|
.every((m) => m.playable == false),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('la raíz del tier gratuito SIEMPRE lleva una carpeta navegable: '
|
|
'audio_service 0.18.18 descarta los rootHints, así que un root de '
|
|
'un solo item PLAYABLE se renderiza vacío en una unidad que solo '
|
|
'acepta FLAG_BROWSABLE', () {
|
|
final libre = ConstructorArbolAuto().raiz(
|
|
incluirMusicaLocal: false,
|
|
premium: false,
|
|
);
|
|
|
|
expect(libre.any((m) => m.playable == false), isTrue);
|
|
});
|
|
|
|
test('el titulo de la unica carpeta gratuita lo decide el LLAMANTE, no '
|
|
'una constante castellana de este archivo (hallazgo 4)', () {
|
|
final libre = ConstructorArbolAuto().raiz(
|
|
incluirMusicaLocal: false,
|
|
premium: false,
|
|
tituloDestacadas: 'Listen',
|
|
);
|
|
|
|
expect(
|
|
libre.single.title,
|
|
'Listen',
|
|
reason:
|
|
'this one label is 100% of the browse tree a free-tier (i.e. '
|
|
'every Play reviewer) driver ever sees; the pure builder stays '
|
|
'AppLocalizations-free, so the handler has to hand it the string',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('hijosDestacadas — item 8/9', () {
|
|
test('mapea a items PLAYABLE con id emisora:<uuid>', () {
|
|
final items = ConstructorArbolAuto().hijosDestacadas(emisorasDestacadas);
|
|
|
|
expect(items, hasLength(emisorasDestacadas.length));
|
|
expect(items.every((m) => m.playable == true), isTrue);
|
|
expect(items.first.id, 'emisora:${emisorasDestacadas.first.uuid}');
|
|
expect(items.every((m) => m.artUri != null), isTrue);
|
|
});
|
|
|
|
test('lista vacía devuelve lista vacía, nunca lanza', () {
|
|
expect(ConstructorArbolAuto().hijosDestacadas(const []), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('respuestaBloqueadaPorEntitlement — item 10', () {
|
|
List<MediaItem>? gate(String id, {required bool premium}) =>
|
|
respuestaBloqueadaPorEntitlement(
|
|
parentMediaId: id,
|
|
premium: premium,
|
|
destacadas: emisorasDestacadas,
|
|
);
|
|
|
|
test('premium: nada se bloquea', () {
|
|
for (final id in [
|
|
AudioService.browsableRootId,
|
|
ConstructorArbolAuto.idFavoritos,
|
|
ConstructorArbolAuto.idTodas,
|
|
ConstructorArbolAuto.idMisEmisoras,
|
|
ConstructorArbolAuto.idMusicaLocal,
|
|
ConstructorArbolAuto.idDestacadas,
|
|
'emisora:uuid-del-catalogo',
|
|
]) {
|
|
expect(gate(id, premium: true), isNull, reason: id);
|
|
}
|
|
});
|
|
|
|
test('free: la raíz y el contenido gratuito PASAN', () {
|
|
expect(gate(AudioService.browsableRootId, premium: false), isNull);
|
|
expect(gate(ConstructorArbolAuto.idDestacadas, premium: false), isNull);
|
|
for (final e in emisorasDestacadas) {
|
|
expect(gate('emisora:${e.uuid}', premium: false), isNull);
|
|
}
|
|
});
|
|
|
|
test('free: el catálogo premium se bloquea', () {
|
|
for (final id in [
|
|
ConstructorArbolAuto.idTodas,
|
|
ConstructorArbolAuto.idMisEmisoras,
|
|
ConstructorArbolAuto.idMusicaLocal,
|
|
ConstructorArbolAuto.idFavoritos,
|
|
ConstructorArbolAuto.idEcualizador,
|
|
'grupo:algo',
|
|
'emisora:uuid-del-catalogo',
|
|
'pista:doc-id',
|
|
]) {
|
|
expect(gate(id, premium: false), isNotNull, reason: id);
|
|
}
|
|
});
|
|
|
|
test('la rama bloqueada devuelve el contenido gratuito, NUNCA una fila '
|
|
'no reproducible: eso es exactamente lo que Play citó', () {
|
|
final bloqueada = gate(ConstructorArbolAuto.idTodas, premium: false);
|
|
|
|
expect(bloqueada, isNotNull);
|
|
expect(bloqueada, isNotEmpty);
|
|
expect(
|
|
bloqueada!.every((m) => m.playable == true),
|
|
isTrue,
|
|
reason: 'una fila no reproducible en el árbol es la cita de Play',
|
|
);
|
|
expect(bloqueada.map((m) => m.id), [
|
|
for (final e in emisorasDestacadas) 'emisora:${e.uuid}',
|
|
]);
|
|
});
|
|
|
|
test('sin destacadas resolubles la rama bloqueada sigue sin inventar una '
|
|
'fila muerta', () {
|
|
final bloqueada = respuestaBloqueadaPorEntitlement(
|
|
parentMediaId: ConstructorArbolAuto.idTodas,
|
|
premium: false,
|
|
destacadas: const [],
|
|
);
|
|
|
|
expect(bloqueada, isEmpty);
|
|
});
|
|
});
|
|
|
|
test('idPremiumInfo / itemPremiumBloqueado ya no existen — item 10', () {
|
|
// Guardia estructural: si alguien los reintroduce, este archivo deja de
|
|
// compilar por el `expect` de abajo, no por un comentario. La única
|
|
// prueba real es que `ConstructorArbolAuto` no expone ningún item no
|
|
// reproducible fuera de las carpetas.
|
|
final libre = ConstructorArbolAuto().raiz(
|
|
incluirMusicaLocal: true,
|
|
premium: false,
|
|
);
|
|
|
|
expect(libre.every((m) => m.id != 'premium:info'), isTrue);
|
|
});
|
|
}
|