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.
This commit is contained in:
@@ -4,6 +4,218 @@ 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', () {
|
||||
@@ -42,7 +254,8 @@ void main() {
|
||||
expect(item.playable, isTrue);
|
||||
});
|
||||
|
||||
test('cae al arte por defecto cuando el favicon es null o vacío', () {
|
||||
test('cae al drawable rotativo correspondiente cuando el favicon es '
|
||||
'null o vacío', () {
|
||||
final sinFavicon = _emisora(
|
||||
uuid: 'uuid-null',
|
||||
nombre: 'Radio Sin Logo',
|
||||
@@ -53,13 +266,67 @@ void main() {
|
||||
nombre: 'Radio Logo Vacio',
|
||||
favicon: '',
|
||||
);
|
||||
const esperado =
|
||||
'android.resource://es.freetimelab.pluriwave/drawable/default_station_art';
|
||||
|
||||
final builder = ConstructorArbolAuto();
|
||||
|
||||
expect(builder.itemEmisora(sinFavicon).artUri.toString(), esperado);
|
||||
expect(builder.itemEmisora(faviconVacio).artUri.toString(), esperado);
|
||||
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_'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,6 +455,7 @@ Emisora _emisora({
|
||||
required String nombre,
|
||||
String url = 'https://stream.demo/radio',
|
||||
String? favicon,
|
||||
String? codec,
|
||||
int? bitrate,
|
||||
}) {
|
||||
return Emisora(
|
||||
@@ -195,6 +463,7 @@ Emisora _emisora({
|
||||
nombre: nombre,
|
||||
url: url,
|
||||
favicon: favicon,
|
||||
codec: codec,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user