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:', () { 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? 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); }); }