Files
pluriwave/test/servicios/navegacion_auto_test.dart
T
Javier Bautista Fernández 35bb180612 feat(auto): browsable Android Auto media tree with play-by-id [size:exception]
Expose PluriWave to Android Auto (projected) as a media app:

- Declare car media support (automotive_app_desc.xml + manifest meta-data)
  so Android Auto discovers the existing MediaBrowserService.
- New navegacion_auto.dart: ConstructorArbolAuto builds the browse tree
  (Favoritos / Todas las emisoras / Mis emisoras, 50-item cap, stable
  emisora:<id> media ids), reproducirPorMediaId routes a car tap to the
  existing playMediaItem pipeline, FuenteEmisorasAutoLocal serves the tree
  cold-start-safe (local favorites/custom stations before Flutter UI runs).
- PluriWaveAudioHandler overrides getChildren/getMediaItem/playFromMediaId
  as thin delegations; playback pipeline untouched.
- EstadoRadio pushes live station snapshots to the browse source and
  reconciles the selected station when playback starts from the car.
- Every playable item ships title + artUri; stations without logo fall
  back to a bundled default art (android.resource://).

Tests: 52/52 green (10 new navegacion_auto, 2 new estado_radio, plus
audio safety-net suites). Handler overrides and native XML are
static-review-only (no Android build env). Size exception approved for a
single reviewable commit.
2026-07-16 16:28:44 +02:00

227 lines
6.2 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('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 arte por defecto 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: '',
);
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);
});
});
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,
int? bitrate,
}) {
return Emisora(
uuid: uuid,
nombre: nombre,
url: url,
favicon: favicon,
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,
}) {}
}