Files
pluriwave/test/servicios/navegacion_auto_test.dart
T
FreeTLab c193650cc4 fix(auto): fall back to brand art and surface quality on dead/missing favicons
Android Auto no longer copies the launcher icon as placeholder art; it
rotates through the same 4 on-brand station_art assets the phone UI
already uses, keyed by the same per-station hash for visual parity.
Malformed or unusable favicon URLs (including a Dart Uri quirk where
'http://' reports hasAuthority=true with an empty host) now fail the
validity gate instead of being handed to the OS media browser as-is.
Browsable items also show codec/bitrate as a subtitle when known.
2026-07-19 13:06:18 +02:00

496 lines
15 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/navegacion_auto.dart';
void main() {
group('faviconUsable', () {
test('rechaza favicons malformados o no http(s), acepta http(s) válidos', () {
final casos = <String?, bool>{
null: false,
'': false,
' ': false,
'ftp://cdn.example.com/logo.png': false,
'cdn.example.com/logo.png': false,
'http://': false,
'/relative/path/logo.png': false,
'not a url at all \$\$\$ ///': false,
'http://cdn.example.com/logo.png': true,
'https://cdn.example.com/logo.png': true,
};
casos.forEach((entrada, esperado) {
expect(
faviconUsable(entrada),
esperado,
reason: 'faviconUsable(${entrada.toString()}) debería ser $esperado',
);
});
});
});
group('indiceArtePara', () {
test('reproduce la misma fórmula que _fallbackArtFor', () {
const seeds = ['', 'a', 'uuid-1234-abcd-real-looking'];
for (final seed in seeds) {
final esperado =
seed.codeUnits.fold<int>(0, (a, b) => a + b) % 4;
expect(indiceArtePara(seed), esperado);
}
});
test('el orden canónico de nombres es aurora, cosmic, pulse, nova '
'(verificado vía artUriPara con seeds que cubren los 4 índices)', () {
// Seeds de una sola letra cuyo codeUnit % 4 cubre los 4 índices, en
// el mismo orden que tarjeta_emisora.dart's _fallbackArtFor:
// 'd'(100)->índice 0 aurora, 'a'(97)->índice 1 cosmic,
// 'b'(98)->índice 2 pulse, 'c'(99)->índice 3 nova.
const indicesEsperados = {'d': 0, 'a': 1, 'b': 2, 'c': 3};
const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova'];
indicesEsperados.forEach((seed, indiceEsperado) {
expect(indiceArtePara(seed), indiceEsperado);
final emisora = _emisora(uuid: seed, nombre: 'Radio $seed');
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_'
'${nombresPorIndice[indiceEsperado]}',
);
});
});
});
group('artUriPara', () {
String nombreEsperadoPara(String uuid) {
final indice = indiceArtePara(uuid);
const nombres = ['aurora', 'cosmic', 'pulse', 'nova'];
return nombres[indice];
}
test('favicon https válido se usa como artUri', () {
final emisora = _emisora(
uuid: 'uuid-logo',
nombre: 'Radio Con Logo',
favicon: 'https://cdn.example.com/logo.png',
);
expect(artUriPara(emisora), emisora.favicon);
});
test('favicon null cae al drawable rotativo correspondiente', () {
final emisora = _emisora(uuid: 'uuid-null', nombre: 'Radio Sin Logo');
final nombre = nombreEsperadoPara(emisora.uuid);
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre',
);
});
test('favicon vacío cae al mismo comportamiento rotativo que null', () {
final emisora = _emisora(
uuid: 'uuid-vacio',
nombre: 'Radio Logo Vacio',
favicon: '',
);
final nombre = nombreEsperadoPara(emisora.uuid);
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre',
);
});
test('favicon malformado cae al mismo comportamiento rotativo', () {
final malformados = [
_emisora(uuid: 'uuid-m1', nombre: 'M1', favicon: 'ftp://x/y.png'),
_emisora(uuid: 'uuid-m2', nombre: 'M2', favicon: 'not a url'),
];
for (final emisora in malformados) {
final nombre = nombreEsperadoPara(emisora.uuid);
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre',
);
}
});
test('uuids distintos que cayeron en índices distintos resuelven a '
'artUriPara distintos', () {
final a = _emisora(uuid: 'uuid-a', nombre: 'A');
final b = _emisora(uuid: 'uuid-bbbb', nombre: 'B');
// Solo tiene sentido si de hecho hashean a índices distintos.
if (indiceArtePara(a.uuid) != indiceArtePara(b.uuid)) {
expect(artUriPara(a), isNot(equals(artUriPara(b))));
}
});
test('el mismo uuid llamado dos veces da resultado idéntico', () {
final emisora = _emisora(uuid: 'uuid-determinista', nombre: 'Det');
expect(artUriPara(emisora), artUriPara(emisora));
});
});
group('subtituloCalidad', () {
test('matriz de codec/bitrate conocidos y desconocidos', () {
expect(
subtituloCalidad(_emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128)),
'128 kbps · MP3',
);
expect(
subtituloCalidad(_emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128)),
'128 kbps',
);
expect(
subtituloCalidad(_emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null)),
'MP3',
);
expect(
subtituloCalidad(_emisora(uuid: 'u4', nombre: 'N', codec: 'mp3', bitrate: 0)),
'MP3',
);
expect(
subtituloCalidad(_emisora(uuid: 'u5', nombre: 'N', codec: null, bitrate: null)),
isNull,
);
expect(
subtituloCalidad(_emisora(uuid: 'u6', nombre: 'N', codec: null, bitrate: 0)),
isNull,
);
expect(
subtituloCalidad(
_emisora(uuid: 'u7', nombre: 'N', codec: ' ', bitrate: null),
),
isNull,
);
expect(
subtituloCalidad(
_emisora(uuid: 'u8', nombre: 'N', codec: ' mp3 ', bitrate: 128),
),
'128 kbps · MP3',
);
});
test('ningún resultado contiene la subcadena "null"', () {
final casos = [
_emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128),
_emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128),
_emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null),
_emisora(uuid: 'u4', nombre: 'N', codec: null, bitrate: null),
];
for (final emisora in casos) {
final resultado = subtituloCalidad(emisora);
expect(resultado?.contains('null') ?? false, isFalse);
}
});
});
group('parity: phone/auto art order', () {
test('el orden de nombres de arte coincide con tarjeta_emisora.dart', () {
// Orden canónico hardcodeado una única vez aquí (mismo patrón que
// tarjeta_emisora.dart:363-367's _fallbackArtFor); debe mantenerse
// sincronizado manualmente si esa lista cambia. _nombresArte es
// privado en navegacion_auto.dart, así que este test verifica el
// orden indirectamente vía artUriPara con seeds que cubren los 4
// índices (mismo patrón que el segundo test de 'indiceArtePara').
const ordenCanonico = ['aurora', 'cosmic', 'pulse', 'nova'];
const seedsPorIndice = ['d', 'a', 'b', 'c'];
for (var indice = 0; indice < ordenCanonico.length; indice++) {
final emisora = _emisora(
uuid: seedsPorIndice[indice],
nombre: 'Radio ${seedsPorIndice[indice]}',
);
expect(indiceArtePara(emisora.uuid), indice);
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_'
'${ordenCanonico[indice]}',
);
}
});
});
group('ConstructorArbolAuto.raiz', () {
test('devuelve exactamente 3 carpetas no reproducibles con los ids '
'esperados', () {
final raiz = ConstructorArbolAuto().raiz();
expect(raiz, hasLength(3));
final ids = raiz.map((item) => item.id).toSet();
expect(
ids,
equals({
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
}),
);
for (final item in raiz) {
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
}
});
});
group('ConstructorArbolAuto.itemEmisora', () {
test('usa el favicon remoto como artUri cuando existe', () {
final emisora = _emisora(
uuid: 'uuid-logo',
nombre: 'Radio Con Logo',
favicon: 'https://cdn.example.com/logo.png',
);
final item = ConstructorArbolAuto().itemEmisora(emisora);
expect(item.id, 'emisora:${emisora.uuid}');
expect(item.title, emisora.nombre);
expect(item.artUri.toString(), emisora.favicon);
expect(item.playable, isTrue);
});
test('cae al drawable rotativo correspondiente cuando el favicon es '
'null o vacío', () {
final sinFavicon = _emisora(
uuid: 'uuid-null',
nombre: 'Radio Sin Logo',
favicon: null,
);
final faviconVacio = _emisora(
uuid: 'uuid-vacio',
nombre: 'Radio Logo Vacio',
favicon: '',
);
final builder = ConstructorArbolAuto();
expect(
builder.itemEmisora(sinFavicon).artUri.toString(),
artUriPara(sinFavicon),
);
expect(
builder.itemEmisora(faviconVacio).artUri.toString(),
artUriPara(faviconVacio),
);
});
test('setea displaySubtitle desde subtituloCalidad cuando el codec y '
'bitrate son conocidos', () {
final emisora = _emisora(
uuid: 'uuid-calidad',
nombre: 'Radio Calidad',
codec: 'mp3',
bitrate: 128,
);
final item = ConstructorArbolAuto().itemEmisora(emisora);
expect(item.displaySubtitle, '128 kbps · MP3');
});
test('omite displaySubtitle (queda null) cuando codec y bitrate son '
'ambos desconocidos', () {
final emisora = _emisora(
uuid: 'uuid-sin-calidad',
nombre: 'Radio Sin Calidad',
);
final item = ConstructorArbolAuto().itemEmisora(emisora);
expect(item.displaySubtitle, isNull);
});
});
group('artUriPara: favicon sin sanear desde Emisora.fromMap', () {
test('un favicon malformado proveniente de favoritos (SQLite) resuelve '
'al drawable rotativo, no al string malformado', () {
// Emisora.fromMap (favoritos) no sanea favicon (a diferencia de
// Emisora.fromApi), así que valores en blanco/malformados llegan sin
// filtrar. Este test fija que la validez-gate de artUriPara es el
// punto que efectivamente los atrapa.
final desdeMapa = Emisora.fromMap({
'uuid': 'uuid-favorito',
'nombre': 'Radio Favorita',
'url': 'https://stream.demo/radio',
'favicon': ' ',
});
final resultado = artUriPara(desdeMapa);
expect(resultado, isNot(equals(' ')));
expect(
resultado,
startsWith('android.resource://es.freetimelab.pluriwave/drawable/station_art_'),
);
});
});
group('ConstructorArbolAuto.hijos', () {
test('limita a 50 items y respeta el orden de ordenarEmisoras', () {
final emisoras = List.generate(
60,
(i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i),
);
final hijos = ConstructorArbolAuto().hijos(
ConstructorArbolAuto.idFavoritos,
emisoras: emisoras,
);
expect(hijos, hasLength(50));
// ordenarEmisoras por calidad ordena por bitrate descendente: el
// primer item debe ser el de mayor bitrate (59) y el ultimo (el #50)
// el de bitrate 10 (59..10 son 50 valores).
expect(hijos.first.id, 'emisora:uuid-59');
expect(hijos.last.id, 'emisora:uuid-10');
});
test('lista vacía cuando no hay emisoras, sin lanzar', () {
final hijos = ConstructorArbolAuto().hijos(
ConstructorArbolAuto.idFavoritos,
emisoras: const [],
);
expect(hijos, isEmpty);
});
test('parentId desconocido devuelve lista vacía', () {
final emisoras = [_emisora(uuid: 'uuid-1', nombre: 'Radio 1')];
final hijos = ConstructorArbolAuto().hijos(
'carpeta-inexistente',
emisoras: emisoras,
);
expect(hijos, isEmpty);
});
});
group('ConstructorArbolAuto.resolver', () {
test('resuelve un uuid conocido a su Emisora', () {
final emisora = _emisora(uuid: 'uuid-conocido', nombre: 'Conocida');
final universo = [emisora, _emisora(uuid: 'otra', nombre: 'Otra')];
final resultado = ConstructorArbolAuto().resolver(
'emisora:uuid-conocido',
universo,
);
expect(resultado, equals(emisora));
});
test(
'devuelve null para id sin prefijo, id malformado o uuid sin match, '
'sin lanzar',
() {
final universo = [_emisora(uuid: 'uuid-conocido', nombre: 'Conocida')];
final builder = ConstructorArbolAuto();
expect(builder.resolver('favoritos', universo), isNull);
expect(builder.resolver('emisora:', universo), isNull);
expect(
builder.resolver('emisora:uuid-desconocido', universo),
isNull,
);
},
);
});
group('reproducirPorMediaId', () {
test(
'resuelve el id y delega a reproducir con un MediaItem con forma de '
'telefono (id=url, extras.uuid)',
() async {
final emisora = _emisora(
uuid: 'uuid-play',
nombre: 'Radio a reproducir',
url: 'https://stream.demo/play',
);
final fuente = _FakeFuenteEmisorasAuto(
porUuidResultado: {emisora.uuid: emisora},
);
MediaItem? recibido;
await reproducirPorMediaId(
'emisora:${emisora.uuid}',
fuente: fuente,
reproducir: (item) async {
recibido = item;
},
);
expect(recibido, isNotNull);
expect(recibido!.id, emisora.url);
expect(recibido!.extras?['uuid'], emisora.uuid);
},
);
test(
'id obsoleto/desconocido no llama a reproducir ni lanza excepción',
() async {
final fuente = _FakeFuenteEmisorasAuto(porUuidResultado: const {});
var llamadas = 0;
await reproducirPorMediaId(
'emisora:uuid-fantasma',
fuente: fuente,
reproducir: (item) async {
llamadas++;
},
);
expect(llamadas, 0);
},
);
});
}
Emisora _emisora({
required String uuid,
required String nombre,
String url = 'https://stream.demo/radio',
String? favicon,
String? codec,
int? bitrate,
}) {
return Emisora(
uuid: uuid,
nombre: nombre,
url: url,
favicon: favicon,
codec: codec,
bitrate: bitrate,
);
}
class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
_FakeFuenteEmisorasAuto({required Map<String, Emisora> porUuidResultado})
: _porUuidResultado = porUuidResultado;
final Map<String, Emisora> _porUuidResultado;
@override
Future<List<Emisora>> favoritos() async => const [];
@override
Future<List<Emisora>> misEmisoras() async => const [];
@override
Future<List<Emisora>> todas() async => const [];
@override
Future<Emisora?> porUuid(String uuid) async => _porUuidResultado[uuid];
@override
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
}) {}
}