Files
pluriwave/test/servicios/navegacion_auto_localizacion_test.dart
FreeTLab 241f81e535 fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche
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.
2026-09-02 22:56:10 +02:00

222 lines
7.8 KiB
Dart

import 'dart:ui' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Every user-readable label of the Android Auto browse tree is translated.
///
/// The owner's rule, after Play saw a Spanish-only car tree on a head unit
/// in any of the 13 shipped locales: anything a user can read gets
/// translated. The two ARB guards (`arb_parity_test`/`arb_anti_copy_test`)
/// only ever see strings that already entered the ARB system, so neither
/// could catch a label hardcoded in `navegacion_auto.dart` that never
/// became a key. This file closes that hole from the CONSUMPTION side
/// (the tree really renders the injected locale);
/// `test/l10n/etiquetas_arbol_auto_test.dart` closes it from the SOURCE
/// side (no new hardcoded label can be added at all).
Future<Map<String, MetadatosPista>> _sinMetadatos(List<String> ids) async =>
const {};
List<NodoLocal> _pistas(int cuantas, {String prefijo = 'cancion'}) =>
List.generate(
cuantas,
(i) => NodoLocal(
documentId: 'doc-$prefijo-$i',
nombre: '${prefijo}_${i.toString().padLeft(3, '0')}.mp3',
esDirectorio: false,
),
);
void main() {
final ingles = lookupAppLocalizations(const Locale('en'));
group('EtiquetasArbolAuto desde AppLocalizations', () {
test('mapea cada etiqueta del árbol a su clave ARB del locale', () {
final etiquetas = etiquetasArbolAutoDesde(ingles);
expect(etiquetas.escuchar, ingles.autoCarpetaEscuchar);
expect(etiquetas.favoritos, ingles.autoCarpetaFavoritos);
expect(etiquetas.todasLasEmisoras, ingles.autoCarpetaTodas);
expect(etiquetas.misEmisoras, ingles.autoCarpetaMisEmisoras);
expect(etiquetas.musicaLocal, ingles.autoCarpetaMusicaLocal);
expect(
etiquetas.musicaLocalNoDisponible,
ingles.autoMusicaLocalNoDisponible,
);
expect(etiquetas.cargarMas, ingles.autoCargarMas);
expect(etiquetas.ordenarPorCalidad, ingles.autoOrdenarPorCalidad);
expect(etiquetas.reproducirCarpeta, ingles.autoReproducirCarpeta);
expect(etiquetas.reproducirAleatorio, ingles.autoReproducirAleatorio);
expect(etiquetas.pistaSinNombre, ingles.autoPistaSinNombre);
});
test('ninguna etiqueta inglesa cae en el castellano de respaldo', () {
final etiquetas = etiquetasArbolAutoDesde(ingles);
const respaldo = EtiquetasArbolAuto.respaldo;
expect(etiquetas.favoritos, isNot(respaldo.favoritos));
expect(etiquetas.todasLasEmisoras, isNot(respaldo.todasLasEmisoras));
expect(etiquetas.misEmisoras, isNot(respaldo.misEmisoras));
expect(etiquetas.musicaLocal, isNot(respaldo.musicaLocal));
expect(
etiquetas.musicaLocalNoDisponible,
isNot(respaldo.musicaLocalNoDisponible),
);
expect(etiquetas.cargarMas, isNot(respaldo.cargarMas));
expect(etiquetas.ordenarPorCalidad, isNot(respaldo.ordenarPorCalidad));
expect(etiquetas.reproducirCarpeta, isNot(respaldo.reproducirCarpeta));
expect(etiquetas.reproducirAleatorio, isNot(respaldo.reproducirAleatorio));
expect(etiquetas.pistaSinNombre, isNot(respaldo.pistaSinNombre));
});
});
group('ConstructorArbolAuto rotula con las etiquetas inyectadas', () {
final constructor = ConstructorArbolAuto(
etiquetas: etiquetasArbolAutoDesde(ingles),
);
test('la raíz premium rotula sus cuatro carpetas en el locale', () {
final raiz = constructor.raiz(incluirMusicaLocal: true, premium: true);
expect(raiz.map((i) => i.title).toList(), [
ingles.autoCarpetaFavoritos,
ingles.autoCarpetaTodas,
ingles.autoCarpetaMisEmisoras,
ingles.autoCarpetaMusicaLocal,
]);
});
test('la raíz gratuita sigue rotulando Escuchar en el locale', () {
final raiz = constructor.raiz(incluirMusicaLocal: false, premium: false);
expect(raiz.single.title, ingles.autoCarpetaEscuchar);
});
test('el item de música local no disponible va en el locale', () {
expect(
constructor.itemLocalNoDisponible().title,
ingles.autoMusicaLocalNoDisponible,
);
});
test('las acciones de carpeta y la entrada de orden van en el '
'locale', () async {
final items = await constructor.itemsLocales(
_pistas(3),
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final titulos = items.map((i) => i.title).toList();
expect(titulos, contains(ingles.autoReproducirCarpeta));
expect(titulos, contains(ingles.autoReproducirAleatorio));
expect(titulos, contains(ingles.autoOrdenarPorCalidad));
});
test('el item "cargar más" de las tres vistas paginadas va en el '
'locale', () async {
final nodos = _pistas(60);
final porNombre = await constructor.itemsLocales(
nodos,
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final porCalidad = await constructor.itemsLocalesOrdenCalidad(
nodos,
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final porBucket = await constructor.itemsLocalesBucket(
_pistas(60, prefijo: 'apple'),
documentIdPadre: 'padre',
idxBucket: 0,
metadatosDe: _sinMetadatos,
);
expect(porNombre.last.title, ingles.autoCargarMas);
expect(porCalidad.last.title, ingles.autoCargarMas);
expect(porBucket.last.title, ingles.autoCargarMas);
});
test('un nombre de fichero en blanco cae en la pista sin nombre del '
'locale', () async {
final items = await constructor.itemsLocales(
const [
NodoLocal(
documentId: 'doc-vacio',
nombre: ' ',
esDirectorio: false,
),
],
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final pista = items.singleWhere((i) => i.id.startsWith('pista:'));
expect(pista.title, ingles.autoPistaSinNombre);
});
test('los rangos alfabéticos NO se traducen: son rangos de letras '
'latinas, no prosa', () async {
final items = await constructor.itemsLocales(
_pistas(60),
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
expect(items.map((i) => i.title), containsAll(['A-F', 'G-M']));
});
});
group('hijosMusicaLocal propaga las etiquetas', () {
test('la carpeta raíz local rotula sus acciones en el locale', () async {
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: _FuenteLocalFalsa(),
etiquetas: etiquetasArbolAutoDesde(ingles),
);
expect(
items!.map((i) => i.title),
containsAll([
ingles.autoReproducirCarpeta,
ingles.autoReproducirAleatorio,
]),
);
});
});
}
/// Minimal in-memory [FuenteMusicaLocalAuto]: one playable track at the
/// tree root, no metadata, native channel available.
class _FuenteLocalFalsa implements FuenteMusicaLocalAuto {
@override
Future<EstadoCarpetaLocal> estadoCarpeta() async =>
EstadoCarpetaLocal.configurada;
@override
Future<List<NodoLocal>> hijos(String documentId) async =>
documentId.isEmpty
? const [
NodoLocal(
documentId: 'doc-0',
nombre: 'cancion.mp3',
esDirectorio: false,
),
]
: const [];
@override
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds) =>
_sinMetadatos(documentIds);
@override
Future<String?> uriContenidoDePista(String documentId) async =>
'content://fake/$documentId';
}