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.
This commit is contained in:
2026-09-02 22:56:10 +02:00
parent a82dcc9c1b
commit 241f81e535
43 changed files with 4446 additions and 310 deletions
@@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/main.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'helpers/handlers_audio.dart';
/// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador.
///
/// El disparador anterior era `View.maybeOf(context) != null` dentro de
@@ -28,6 +30,8 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () {
test('resumed + coche ya suscrito + latch libre invalida', () {
expect(
@@ -109,7 +113,7 @@ void main() {
testWidgets('arranque headless: hay View desde el primer frame, pero sin '
'Activity ni coche suscrito el latch NO se gasta y sigue disponible '
'para cuando el coche por fin navegue', (tester) async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
var invalidaciones = 0;
registrarInvalidacionArbolAuto(() => invalidaciones++);
@@ -144,7 +148,7 @@ void main() {
testWidgets('con el coche YA suscrito, adjuntar una Activity (resumed) '
'empuja de verdad por el stream de hijos de la raíz', (tester) async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
// El coche navegó la raíz durante el arranque headless: el sujeto
@@ -190,7 +194,7 @@ void main() {
group('hayCocheSuscritoAlArbol', () {
test('es false sin handler suscrito y true en cuanto el coche navega un '
'id', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
expect(hayCocheSuscritoAlArbol(), isFalse);
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Test-isolation seam for [PluriWaveAudioHandler].
///
/// A handler nobody releases keeps running after the test that built it: its
/// terminal-state floor timer, its `ControladorReconexion` backoff (1/2/4/8/16
/// s, longer than most of the tests that arm it) and anything still queued on
/// its source-change chain. When one of those finally performs a source change
/// it calls `_crearPlayer()`, which reads the CURRENT
/// [PluriWaveAudioHandler.fabricaReproductorPrueba] — so a dead handler builds
/// a double bound to a LATER test's script and drives it, incrementing that
/// test's counters for work it never asked for.
///
/// That is why `servicio_audio_transporte_test.dart` behaved differently run
/// alone and run inside the whole suite. A suite that passes under those
/// conditions passes by luck, and luck runs out on a broken build exactly when
/// it matters.
///
/// Usage — call ONCE at the top of `main()` and build every handler through
/// the returned function:
///
/// ```dart
/// final crearHandler = registrarHandlersLiberables();
/// ...
/// final handler = crearHandler();
/// ```
///
/// The `tearDown` it registers covers every group in the file.
PluriWaveAudioHandler Function() registrarHandlersLiberables() {
final creados = <PluriWaveAudioHandler>[];
tearDown(() async {
// Released in reverse creation order so a handler built on top of an
// earlier one is torn down first. `liberar` is idempotent, so a test that
// already released its own handler is fine.
for (final handler in creados.reversed) {
await handler.liberar();
}
creados.clear();
});
return () {
final handler = PluriWaveAudioHandler();
creados.add(handler);
return handler;
};
}
Binary file not shown.
+6
View File
@@ -300,4 +300,10 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
'restaurarCompras',
), // iap-freemium-unlock new key -- "Restaurar compras" is the standard
// Portuguese store wording and coincides with es word for word.
(
'pt',
'autoCarpetaFavoritos',
), // fix/auto-quality-guidelines car-tree label -- "Favoritos" is the same
// word in pt and es, exactly like the already-listed ('pt',
// 'favoritesTitle') above, which carries this very value.
};
@@ -0,0 +1,125 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Free-tier featured set (fix/auto-quality-guidelines, item 6).
///
/// The whole compliance story rests on this: a Play reviewer on a fresh
/// install is ALWAYS free tier, has no network catalogue snapshot, no
/// favourites, no custom stations and no `ultima_emisora_v1` — so the free
/// root's single folder MUST still resolve to real, playable stations from
/// nothing but the binary itself.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const ultima = Emisora(
uuid: 'uuid-ultima',
nombre: 'Ultima escuchada',
url: 'https://ultima.example/stream',
);
group('resolverEmisorasDestacadas', () {
test('cold bind: sin red, sin EstadoRadio y con prefs vacías devuelve '
'>= 3 emisoras reales', () async {
SharedPreferences.setMockInitialValues({});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.length, greaterThanOrEqualTo(3));
expect(
destacadas.every((e) => e.uuid.isNotEmpty),
isTrue,
reason: 'un uuid vacío no se puede resolver desde emisora:<uuid>',
);
expect(
destacadas.every(
(e) => e.url.startsWith('http://') || e.url.startsWith('https://'),
),
isTrue,
);
expect(
destacadas.map((e) => e.uuid).toSet().length,
destacadas.length,
reason: 'uuids duplicados romperían porUuid',
);
});
test('con ultima_emisora_v1 presente: va PRIMERA y no se duplica',
() async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(ultima.toMap()),
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.first.uuid, ultima.uuid);
expect(
destacadas.where((e) => e.uuid == ultima.uuid).length,
1,
reason: 'la última escuchada no puede aparecer dos veces',
);
expect(destacadas.length, emisorasDestacadas.length + 1);
});
test('la última escuchada YA curada no añade una segunda fila', () async {
final yaCurada = emisorasDestacadas.first;
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(yaCurada.toMap()),
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.first.uuid, yaCurada.uuid);
expect(destacadas.length, emisorasDestacadas.length);
});
test('ultima_emisora_v1 corrupta degrada al set curado, nunca lanza',
() async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: 'no-es-json{{',
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.length, emisorasDestacadas.length);
});
});
group('esEmisoraGratuitaPorUuid', () {
test('un uuid curado es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(
await esEmisoraGratuitaPorUuid(emisorasDestacadas.first.uuid),
isTrue,
);
});
test('la última escuchada es gratuita aunque no esté curada', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(ultima.toMap()),
});
expect(await esEmisoraGratuitaPorUuid(ultima.uuid), isTrue);
});
test('un uuid del catálogo Radio Browser NO es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(await esEmisoraGratuitaPorUuid('uuid-del-catalogo'), isFalse);
});
test('uuid vacío nunca es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(await esEmisoraGratuitaPorUuid(''), isFalse);
});
});
test('claveUltimaEmisora coincide con la que persiste EstadoRadio', () {
expect(claveUltimaEmisora, 'ultima_emisora_v1');
});
}
@@ -0,0 +1,267 @@
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);
});
}
+115 -79
View File
@@ -1,106 +1,142 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Android Auto entitlement gating (android-auto-media spec "Free-Tier
/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content",
/// design.md ADR-4). All pure — no handler instantiation needed
/// (`PluriWaveAudioHandler` cannot be constructed in a unit test).
/// Android Auto entitlement gating — the id-shape matrix.
///
/// REWRITTEN for fix/auto-quality-guidelines item 10. This suite used to
/// assert the opposite design: that every non-root id, for a free-tier user,
/// collapsed to a single non-playable `premium:info` row. Google Play cited
/// that browse tree against the Android for Cars App Quality Guidelines, so
/// the contract is now content-scoping — the free tier sees LESS, never a
/// row that does nothing.
///
/// [respuestaBloqueadaPorEntitlement]'s return VALUE is covered in
/// `navegacion_auto_destacadas_test.dart`; this file pins the decision
/// surface ([idPermitidoEnFree]) across every id shape the tree can produce,
/// including the stale/deep-linked ones a head unit's cached tree replays.
void main() {
group('raiz(premium:) — root keeps its labels for every tier', () {
test('premium: identical to today\'s tree (regression guard)', () {
final constructor = ConstructorArbolAuto();
const gratuitas = [
Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'),
Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'),
];
final premiumConLocal = constructor.raiz(
incluirMusicaLocal: true,
premium: true,
group('idPermitidoEnFree', () {
test('la raíz siempre pasa: es lo único que decide qué ve el tier', () {
expect(
idPermitidoEnFree(
AudioService.browsableRootId,
destacadas: gratuitas,
),
isTrue,
);
final premiumSinLocal = constructor.raiz(
incluirMusicaLocal: false,
premium: true,
);
expect(premiumConLocal.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
]);
expect(premiumConLocal.every((m) => m.playable == false), isTrue);
expect(premiumConLocal.every((m) => m.displaySubtitle == null), isTrue);
expect(premiumSinLocal.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
]);
});
test('free: same folder ids/titles, non-blank, never playable', () {
final constructor = ConstructorArbolAuto();
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
expect(libre, isNotEmpty);
expect(libre.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
]);
expect(libre.every((m) => m.playable == false), isTrue);
});
});
test(
'itemPremiumBloqueado(): id fijo, no reproducible, etiqueta premium',
() {
final item = ConstructorArbolAuto().itemPremiumBloqueado();
expect(item.id, 'premium:info');
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
},
);
group('respuestaBloqueadaPorEntitlement — backstop de navegacion', () {
test('root nunca es bloqueada (root siempre resuelve via raiz)', () {
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: AudioService.browsableRootId,
premium: false,
test('la carpeta gratuita pasa', () {
expect(
idPermitidoEnFree(
ConstructorArbolAuto.idDestacadas,
destacadas: gratuitas,
),
isTrue,
);
expect(respuesta, isNull);
});
test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () {
test('un emisora:<uuid> del set gratuito pasa', () {
for (final e in gratuitas) {
expect(
idPermitidoEnFree('emisora:${e.uuid}', destacadas: gratuitas),
isTrue,
reason: e.uuid,
);
}
});
test('las carpetas premium NO pasan', () {
for (final id in [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idEcualizador,
// Stale/deep-linked id from before a downgrade — the backstop must
// not special-case known ids (Spec "Stale folder id bypass
// attempt").
'emisora:algun-uuid-viejo',
'grupo:algo',
]) {
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: id,
premium: false,
);
expect(respuesta, hasLength(1));
expect(respuesta!.single.id, 'premium:info');
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
reason: id);
}
});
test('cualquier id no-root, en premium, no es bloqueada', () {
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: ConstructorArbolAuto.idFavoritos,
premium: true,
test('un id rancio/deep-link de antes de una bajada de tier NO pasa: '
'ésa es la propiedad de seguridad que el rediseño tenía que '
'conservar', () {
for (final id in [
'emisora:uuid-del-catalogo',
'grupo:algun-grupo',
'pista:doc-id',
'carpeta_local:doc-id',
'carpeta_local_reproducir:doc-id',
'carpeta_local_aleatorio:doc-id',
'eq_preset:Rock',
'premium:info', // la fila muerta que ya no existe
'',
]) {
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
reason: id);
}
});
test('emisora: con uuid vacío NO pasa (id malformado, no comodín)', () {
expect(idPermitidoEnFree('emisora:', destacadas: gratuitas), isFalse);
});
test('con el set gratuito vacío solo pasan la raíz y su carpeta', () {
expect(
idPermitidoEnFree(AudioService.browsableRootId, destacadas: const []),
isTrue,
);
expect(
idPermitidoEnFree(
ConstructorArbolAuto.idDestacadas,
destacadas: const [],
),
isTrue,
);
expect(
idPermitidoEnFree('emisora:libre-1', destacadas: const []),
isFalse,
);
});
});
group('respuestaBloqueadaPorEntitlement', () {
test('premium: ningún id se bloquea, ni siquiera uno inventado', () {
for (final id in [
AudioService.browsableRootId,
ConstructorArbolAuto.idTodas,
'emisora:cualquier-cosa',
'basura',
]) {
expect(
respuestaBloqueadaPorEntitlement(
parentMediaId: id,
premium: true,
destacadas: gratuitas,
),
isNull,
reason: id,
);
}
});
test('free: lo bloqueado NUNCA incluye un item no reproducible', () {
final bloqueada = respuestaBloqueadaPorEntitlement(
parentMediaId: ConstructorArbolAuto.idMisEmisoras,
premium: false,
destacadas: gratuitas,
);
expect(respuesta, isNull);
expect(bloqueada, isNotNull);
expect(bloqueada!.every((m) => m.playable == true), isTrue);
});
});
}
@@ -0,0 +1,221 @@
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';
}
+5 -3
View File
@@ -2135,9 +2135,11 @@ void main() {
/// EXPLICAR el problema en vez de abrirse vacío (una carpeta vacía se
/// lee como «no tengo música», que es justo la conclusión equivocada).
///
/// La etiqueta va en castellano hardcodeado, como TODAS las etiquetas
/// del árbol del coche en `navegacion_auto.dart` (ver
/// `itemPremiumBloqueado`): convención establecida, nunca `AppLocalizations`.
/// La etiqueta sale de `EtiquetasArbolAuto.musicaLocalNoDisponible`,
/// como TODAS las etiquetas legibles del árbol del coche: todo lo que
/// un usuario lee se traduce. Este test solo comprueba que hay UNA
/// etiqueta no vacía; el idioma concreto lo cubre
/// `navegacion_auto_localizacion_test.dart`.
test('canalNoDisponible y carpeta vacía: la raíz local devuelve un item '
'explicativo NO reproducible, no una carpeta vacía', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
@@ -0,0 +1,679 @@
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();
}
}
@@ -2,6 +2,8 @@ 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
@@ -28,6 +30,8 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
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);
@@ -46,7 +50,7 @@ void main() {
group('registrarHandler (A — seeding)', () {
test('consults the injected read port exactly once and seeds the handler '
'with the persisted value', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
var lecturas = 0;
registrarHandler(
@@ -68,7 +72,7 @@ void main() {
test('a read failure leaves the handler on the safe default instead of '
'propagating', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(
handler,
@@ -81,7 +85,7 @@ void main() {
test('without a read port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
await handler.setEcualizadorActivo(false);
registrarHandler(handler);
@@ -106,7 +110,7 @@ void main() {
'starts from the persisted value, not from a hardcoded default',
() async {
// One engine does the read `registrarHandler` performs in main.dart.
final primero = PluriWaveAudioHandler();
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
@@ -123,7 +127,7 @@ void main() {
// Now the construction window: a handler built by `AudioService.init`'s
// builder, with no port of its own yet.
final segundo = PluriWaveAudioHandler();
final segundo = crearHandler();
expect(
segundo.ecualizadorActivo,
@@ -136,12 +140,12 @@ void main() {
test('the cache follows what the handler itself writes, in both '
'directions', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
await handler.setEcualizadorActivo(false);
expect(
PluriWaveAudioHandler().ecualizadorActivo,
crearHandler().ecualizadorActivo,
isFalse,
reason:
'the write side of the cache: a toggle must be visible to the '
@@ -149,14 +153,14 @@ void main() {
);
await handler.setEcualizadorActivo(true);
expect(PluriWaveAudioHandler().ecualizadorActivo, isTrue);
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 = PluriWaveAudioHandler();
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
@@ -177,7 +181,7 @@ void main() {
});
test('seeding from disk does NOT write back to disk', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
@@ -192,7 +196,7 @@ void main() {
});
test('a failing write port never breaks the toggle', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(
handler,
@@ -248,7 +252,7 @@ void main() {
group('customAction dispatch (C — zero coverage before this)', () {
test('the accionEqToggle literal routes through decidirToggleEq', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
await handler.setEcualizadorActivo(true);
@@ -261,7 +265,7 @@ void main() {
test('a car toggle persists through the same write port as a phone '
'toggle', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
@@ -282,7 +286,7 @@ void main() {
});
test('an unknown custom action is a silent no-op', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
final antes = handler.ecualizadorActivo;
@@ -380,7 +384,7 @@ void main() {
test('the first non-idle event re-asserts the native effect exactly '
'once, and staying active never re-asserts again', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
@@ -410,7 +414,7 @@ void main() {
test('going idle re-arms the edge, so stop + play re-asserts again — '
'this is the `_reproductorActivo = proc != idle` line', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
@@ -435,7 +439,7 @@ void main() {
});
test('with no native effect attached nothing is ever re-asserted', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
// `_eqDisponible` is false off-device, which is also the real
// "device has no Equalizer effect" case.
@@ -451,7 +455,7 @@ void main() {
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();
final handler = crearHandler();
registrarHandler(handler);
handler.playbackState.add(
handler.playbackState.value.copyWith(
+28 -8
View File
@@ -6,6 +6,8 @@ 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';
/// Android Auto play-path backstop (design.md ADR-4, android-auto-media
/// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station
/// Playback Unaffected By Free Tier"): `playFromMediaId`, `playFromSearch`,
@@ -20,12 +22,30 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('free tier: bloquea cualquier cambio de emisora/salto', () {
expect(debeBloquearCambioDeEmisora(premium: false), isTrue);
final crearHandler = registrarHandlersLiberables();
// fix/auto-quality-guidelines item 11: the gate is CONTENT-scoped now.
// Blocking every switch for the free tier is what made the car surface
// useless for the only tier a Play reviewer can be in.
test('free tier: bloquea una emisora del catálogo premium', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
isTrue,
);
});
test('free tier: NO bloquea una emisora del set gratuito', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
isFalse,
);
});
test('premium: nunca bloquea', () {
expect(debeBloquearCambioDeEmisora(premium: true), isFalse);
expect(
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
isFalse,
);
});
/// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la
@@ -51,7 +71,7 @@ void main() {
test('registrarHandler conecta la invalidación al handler: una llamada '
'notifica la raíz Y Música Local', () async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
registrarHandler(handler);
final raiz = <Map<String, dynamic>>[];
@@ -86,13 +106,13 @@ void main() {
group('subscribeToChildren', () {
test('el sujeto arranca SIN valor: nada que reenviar en la primera '
'suscripción, así que no hay notifyChildrenChanged espurio', () {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
expect(handler.subscribeToChildren('musica_local').hasValue, isFalse);
});
test('memoiza por id: dos llamadas devuelven el MISMO stream', () {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
expect(
identical(
@@ -112,7 +132,7 @@ void main() {
test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito',
() async {
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
final stream = handler.subscribeToChildren('musica_local');
final recibidos = <Map<String, dynamic>>[];
final sub = stream.listen(recibidos.add);
@@ -143,7 +163,7 @@ void main() {
Future<List<String>> idsRaizCon(EstadoCarpetaLocal estado) async {
registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado));
final handler = PluriWaveAudioHandler();
final handler = crearHandler();
final items = await handler.getChildren(AudioService.browsableRootId);
return items.map((i) => i.id).toList();
}
@@ -0,0 +1,736 @@
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/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// Android for Cars App Quality Guidelines — transport state machine.
///
/// Google Play returned "Approved with Issues" against version code 157:
/// «clicking on stop button makes the entire app useless». These tests drive
/// the REAL [PluriWaveAudioHandler] against a scripted [AudioPlayer] double
/// (installed through [PluriWaveAudioHandler.fabricaReproductorPrueba]) so
/// the published `playbackState` sequence — the only thing Android Auto ever
/// sees — can be asserted end to end.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
});
group('stop() durante cambios de fuente en vuelo (P0 — botón Stop)', () {
test(
'dos cambios encolados y un stop: el ultimo estado publicado es idle, '
'nunca vuelve a loading',
() async {
final handler = crearHandler();
final publicados = <AudioProcessingState>[];
final sub = handler.playbackState.listen(
(estado) => publicados.add(estado.processingState),
);
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://b', title: 'B'))
.catchError((_) {}),
);
await handler.stop();
// Drain both queued source changes: they must discover the stale
// revision WITHOUT ever publishing again.
await pumpEventQueue();
await sub.cancel();
expect(
publicados.last,
AudioProcessingState.idle,
reason:
'a stale queued source change must never rewrite `loading` over '
'the `idle` that stop() published — that is what leaves Android '
'Auto spinning forever on a dead session. Secuencia: $publicados',
);
},
);
});
group('pause() durante un cambio de fuente en vuelo (P0 — botón Pausa)', () {
test('la emisora NO arranca: _player.play() nunca se invoca', () async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
expect(
guion.llamadasSetUrl,
1,
reason: 'precondicion: el cambio de fuente esta en vuelo',
);
await handler.pause();
guion.completerSetUrl!.complete(null);
await pumpEventQueue();
expect(
guion.llamadasPlay,
0,
reason:
'the user pressed Pause while the station was loading — the load '
'finishing afterwards must never start playback behind their back',
);
});
test(
'y el coche no se queda en el spinner: el estado publicado sale de '
'loading',
() async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
await handler.pause();
guion.completerSetUrl!.complete(null);
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
isNot(AudioProcessingState.loading),
reason:
'withholding the play() must not leave the car showing the '
'spinner the load started with — nothing else will publish, '
'because the player never transitions',
);
expect(handler.playbackState.value.playing, isFalse);
},
);
});
group('Suelo de estado terminal (P0 — nunca un spinner eterno)', () {
setUp(() {
PluriWaveAudioHandler.vigilanciaTransitoria = const Duration(
milliseconds: 60,
);
});
tearDown(() {
PluriWaveAudioHandler.vigilanciaTransitoria =
PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto;
});
test(
'un buffering publicado SIN carga viva cae a un estado terminal dentro '
'de la ventana',
() async {
final handler = crearHandler();
handler.manejarEstadoPlayer(
PlayerState(false, ProcessingState.buffering),
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'precondicion: el coche esta viendo el spinner',
);
await Future<void>.delayed(const Duration(milliseconds: 250));
expect(
handler.playbackState.value.processingState,
isIn(const [
AudioProcessingState.ready,
AudioProcessingState.idle,
AudioProcessingState.error,
]),
reason:
'the only exits from loading/buffering are player events that '
'.distinct() can swallow — without a floor the car spins '
'forever over a session nobody is driving',
);
},
);
test('una carga LEGITIMA en vuelo no se interrumpe', () async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
await Future<void>.delayed(const Duration(milliseconds: 250));
expect(
handler.playbackState.value.processingState,
AudioProcessingState.loading,
reason:
'the watchdog is a floor for a STALLED state machine, not a cap '
'on how long a slow station may take to open',
);
});
test(
'un mount estancado NO cae en un idle mudo: publica un motivo legible '
'(hallazgo 3)',
() async {
final handler = crearHandler();
// Exact shape of a stalled icecast mount: the socket opens, `setUrl`
// returns inside the timeout (so no TimeoutException and no
// PlayerException — `_esErrorDeRed` never fires and the reconnect
// machine is never entered), and then no data ever arrives. State
// sits at buffering with `_cambiosEnVuelo` already back to 0.
handler.manejarEstadoPlayer(
PlayerState(false, ProcessingState.buffering),
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'a bare `idle` routes straight into `AudioService._stop()` '
'(audio_service.dart:1131-1135), so the driver got silence, a '
'dead session and no explanation. `error` keeps the session '
'alive and carries a message',
);
expect(
estado.errorMessage,
isNotNull,
reason:
'the floor must say something the driver can read and act on',
);
},
);
test(
'un rebuffer normal a mitad de emision NO se convierte en error '
'(regresion: el suelo miraba solo processingState)',
() async {
final handler = crearHandler();
// Established playback: the stream delivered audio and ExoPlayer
// reached `ready` while playing. This is what separates a re-buffer
// from a mount that never produced a byte.
handler.manejarEstadoPlayer(PlayerState(true, ProcessingState.ready));
// Ordinary mid-stream re-buffer: `bufferForPlaybackAfterRebuffer` is
// 5 s, so a tunnel or an LTE handover routinely holds this state for
// longer than the floor's window. ExoPlayer raised no error, so
// `_intentarReconexion` never ran and `reintentoPendiente` is false;
// `_cambiosEnVuelo` is already 0 because the non-blocking
// `_iniciarPlaySinBloquear` returned long ago.
handler.manejarEstadoPlayer(
PlayerState(true, ProcessingState.buffering),
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'precondicion: el reproductor esta rellenando el buffer',
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
isNot(AudioProcessingState.error),
reason:
'a self-recovering re-buffer over live audio must never be '
'converted into a hard STATE_ERROR: the driver is in a tunnel, '
'not on a dead mount, and `_errorTerminal` latches so nothing '
'the player emits afterwards could undo it',
);
expect(
estado.playing,
isTrue,
reason:
'the player still owns the timeline — publishing `playing: '
'false` over it desynchronises the head unit transport row',
);
expect(
estado.errorMessage,
isNull,
reason: 'nothing failed, so there is nothing to tell the driver',
);
},
);
test(
'un mount que NUNCA entrego audio sigue cayendo al suelo aunque el '
'reproductor diga playing: true',
() async {
final handler = crearHandler();
// `just_audio`'s `playing` is the play-when-ready intent flag: it
// flips to true the moment `play()` is called, whether or not a
// single byte ever arrives. So the stalled icecast mount the floor
// exists for reports `playing: true` too — `playing` alone can never
// be the discriminator.
handler.manejarEstadoPlayer(
PlayerState(true, ProcessingState.buffering),
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'no `ready` was ever reached on this run, so nothing is '
're-buffering: the driver is staring at a spinner and the floor '
'is the only exit',
);
expect(estado.errorMessage, isNotNull);
},
);
test(
'la ventana del suelo respeta el presupuesto de diez segundos hasta el '
'primer mensaje',
() {
expect(
PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto,
lessThanOrEqualTo(const Duration(seconds: 10)),
reason:
'the floor is the ONLY exit for a stalled mount, so its window '
'IS the time-to-first-message for that failure mode; twenty '
"seconds was double the code's own cited budget",
);
},
);
});
group('Error terminal de reproduccion: la sesion sobrevive (hallazgo 2)', () {
test(
'el ultimo estado publicado es error CON mensaje, y no lo sigue un idle',
() async {
// A non-network failure: `_esErrorDeRed` is false, so this goes
// straight down the terminal path instead of the reconnect machine.
guion.errorSetUrl = Exception('mount muerto');
final handler = crearHandler();
final publicados = <AudioProcessingState>[];
final sub = handler.playbackState.listen(
(estado) => publicados.add(estado.processingState),
);
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
// What `_player.stop()` really does: just_audio.dart:1016-1025
// switches to the idle dummy platform, so `playerStateStream` emits a
// distinct (playing:false, idle). The double cannot do that on its
// own, so the test drives the exact event the real player would.
guion.ultimoReproductor!.emitir(
PlayerState(false, ProcessingState.idle),
);
await pumpEventQueue();
await sub.cancel();
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'forwarding that idle makes audio_service call '
'AudioService._stop() -> deactivateMediaSession() + stopSelf(), '
'so PluriWave dropped off the Android Auto playback surface a '
'single event-loop turn after showing the error',
);
expect(estado.errorMessage, isNotNull);
expect(
publicados.last,
isNot(AudioProcessingState.idle),
reason: 'secuencia publicada: $publicados',
);
expect(
handler.mediaItem.value,
isNotNull,
reason:
'Android Auto drops a session with no metadata to show, so '
'nulling the media item on the error path makes the app vanish '
'from the car pane even when the state itself survives — the '
'station that failed has to keep its name on screen',
);
},
);
test(
'un stop() del usuario DESPUES del error sigue produciendo un idle real '
'(la sesion tiene que poder morir cuando el conductor lo pide)',
() async {
guion.errorSetUrl = Exception('mount muerto');
final handler = crearHandler();
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
reason: 'precondicion',
);
await handler.stop();
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.idle,
reason:
'suppressing the error-driven idle must NEVER make the Stop '
'button unkillable — that is the original citation',
);
},
);
});
group('Presupuesto de tiempo hasta el primer mensaje (<= 10 s)', () {
tearDown(() {
PluriWaveAudioHandler.timeoutCambioFuente =
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto;
});
test('el timeout por defecto deja el primer mensaje dentro de 10 s', () {
expect(
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto,
lessThanOrEqualTo(const Duration(seconds: 10)),
reason:
'Android for Cars App Quality Guidelines allow ten seconds before '
'the driver must be told something; the first attempt alone used '
'to burn twelve',
);
});
test(
'una fuente que nunca responde publica un mensaje visible al agotar el '
'primer intento',
() async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 100,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
await Future<void>.delayed(const Duration(milliseconds: 300));
expect(
handler.playbackState.value.errorMessage,
isNotNull,
reason:
'the backoff used to publish `buffering` with errorMessage: '
'null, so the car showed a silent spinner for the whole ~100 s '
'reconnect window',
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'still retrying — the message rides ON TOP of the retry',
);
},
);
test('los reintentos siguen DETRAS del mensaje', () async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 100,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
// First backoff delay is 1 s (ControladorReconexion default).
await Future<void>.delayed(const Duration(milliseconds: 1300));
expect(
guion.llamadasSetUrl,
greaterThanOrEqualTo(2),
reason: 'the reconnect machine keeps working after the first message',
);
expect(
handler.playbackState.value.errorMessage,
isNotNull,
reason:
'and the message survives the retry: re-entering `_cambiarFuente` '
'must not blank the car screen back to a silent spinner',
);
});
});
/// A handler nobody released goes on running: its terminal-state floor
/// timer, its `ControladorReconexion` backoff (1/2/4/8/16 s, which easily
/// outlives the test that armed it) and whatever is still queued on
/// `_colaCambioFuente`. When one of those finally performs a source change
/// it calls `_crearPlayer()`, which reads the CURRENT static
/// `fabricaReproductorPrueba` — so it builds a double bound to a LATER
/// test's script and increments that test's counters for work it never
/// asked for. A suite that passes under those conditions passes by luck.
group('Liberacion del handler: nada sobrevive al test que lo creo', () {
tearDown(() {
PluriWaveAudioHandler.timeoutCambioFuente =
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto;
});
test(
'un handler liberado NO vuelve a construir un reproductor contra la '
'fabrica del test siguiente',
() async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 60,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
// Long enough for the source-change timeout to fire and the reconnect
// machine to arm its first backoff retry (1 s).
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(
guion.llamadasSetUrl,
1,
reason: 'precondicion: hay un reintento armado detras',
);
await handler.liberar();
// Exactly what the framework does between tests: a brand-new script
// and a factory bound to it. Nothing from the previous test may
// reach this.
final guionSiguiente = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) =>
_ReproductorFalso(guionSiguiente, pipeline, carga);
await Future<void>.delayed(const Duration(milliseconds: 1400));
expect(
guionSiguiente.llamadasSetUrl,
0,
reason:
'the leaked backoff retry re-enters `_cambiarFuente`, which '
'calls `_crearPlayer()` and therefore reads whatever factory is '
'installed NOW — attributing a dead handler s work to the test '
'that happens to be running',
);
expect(
guionSiguiente.ultimoReproductor,
isNull,
reason: 'no player at all may be built against the new script',
);
},
);
test('liberar() es idempotente', () async {
final handler = crearHandler();
await handler.liberar();
await handler.liberar();
});
});
group('Idioma de la superficie del coche (motor sin Activity)', () {
tearDown(() {
PluriWaveAudioHandler.lectorLocalePlataforma =
PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto;
});
/// Drives a NON-network failure through the real source-change path so the
/// terminal error message published to the car can be read back.
Future<String?> mensajeDeError(PluriWaveAudioHandler handler) async {
guion.errorSetUrl = Exception('boom');
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
return handler.playbackState.value.errorMessage;
}
test(
'sin configurarLocalizaciones, los mensajes salen en el locale de la '
'plataforma, no en es',
() async {
PluriWaveAudioHandler.lectorLocalePlataforma = () =>
const Locale('en');
final handler = crearHandler();
final mensaje = await mensajeDeError(handler);
expect(
mensaje,
lookupAppLocalizations(const Locale('en')).audioErrorUnexpectedPlayback,
reason:
'`configurarLocalizaciones` only ever runs from '
'`mini_reproductor.dart` didChangeDependencies. The headless '
'Android Auto engine has no Activity and no widget tree, so it '
'never ran there and every car message came out in Spanish',
);
expect(
mensaje,
isNot(
lookupAppLocalizations(
const Locale('es'),
).audioErrorUnexpectedPlayback,
),
);
},
);
test('un locale de plataforma no soportado conserva el respaldo es', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('sw');
final handler = crearHandler();
expect(
await mensajeDeError(handler),
lookupAppLocalizations(const Locale('es')).audioErrorUnexpectedPlayback,
reason: 'the existing fallback must survive an unresolvable locale',
);
});
test('configurarLocalizaciones sigue teniendo prioridad', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
final handler = crearHandler();
handler.configurarLocalizaciones(
lookupAppLocalizations(const Locale('fr')),
);
expect(
await mensajeDeError(handler),
lookupAppLocalizations(const Locale('fr')).audioErrorUnexpectedPlayback,
reason: 'the phone UI still owns the locale once a widget tree exists',
);
});
});
}
/// Shared script/observation record for every [_ReproductorFalso] the handler
/// builds (it rebuilds its player on every source change, so counters cannot
/// live on the instance).
class _GuionReproductor {
int llamadasPlay = 0;
int llamadasSetUrl = 0;
final urlsSolicitadas = <String>[];
/// When set, `setUrl` completes with this error instead of succeeding.
Object? errorSetUrl;
/// When true, `setUrl` never completes (simulates a dead stream that only
/// the source-change timeout can end).
bool setUrlCuelga = false;
/// When set, `setUrl` returns this completer's future, so a test can hold a
/// source change mid-flight and release it after acting on the handler.
Completer<Duration?>? completerSetUrl;
/// The handler rebuilds its player on every source change, so a test that
/// needs to drive a player event has to reach the LATEST instance.
_ReproductorFalso? ultimoReproductor;
}
/// A [AudioPlayer] whose platform-touching methods are replaced by the script
/// above. Everything else (the rx subjects the constructor wires up) is the
/// real thing, so the handler's stream plumbing is exercised unchanged.
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();
/// Drives the exact `playerStateStream` event the real player would emit.
void emitir(PlayerState estado) {
if (!_estados.isClosed) _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,
}) {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
if (_guion.setUrlCuelga) return Completer<Duration?>().future;
final pendiente = _guion.completerSetUrl;
if (pendiente != null) return pendiente.future;
final error = _guion.errorSetUrl;
if (error != null) return Future<Duration?>.error(error);
return Future<Duration?>.value(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();
}
}