import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/pista_local.dart'; import 'package:pluriwave/servicios/musica_local_auto.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('CacheMetadatosSesion', () { test( 'almacena hasta 256 entradas; la entrada 257 desaloja la ' 'menos-recientemente-ACCEDIDA (no solo la menos recientemente ' 'insertada)', () { final cache = CacheMetadatosSesion(); for (var i = 0; i < 256; i++) { cache.guardar('doc-$i', MetadatosPista(titulo: 'T$i')); } expect(cache.obtener('doc-0'), isNotNull); // Accede a doc-0 (la más vieja) para refrescar su recencia antes de // insertar la entrada 257 — así doc-1 (no doc-0) debe ser la // desalojada. cache.obtener('doc-0'); cache.guardar('doc-256', const MetadatosPista(titulo: 'T256')); expect(cache.obtener('doc-0'), isNotNull); expect(cache.obtener('doc-1'), isNull); expect(cache.obtener('doc-256'), isNotNull); }, ); test( 'sin refrescar recencia: insertar la entrada 257 desaloja la ' 'entrada 0 (la menos recientemente insertada Y accedida)', () { final cache = CacheMetadatosSesion(); for (var i = 0; i < 256; i++) { cache.guardar('doc-$i', MetadatosPista(titulo: 'T$i')); } cache.guardar('doc-256', const MetadatosPista(titulo: 'T256')); expect(cache.obtener('doc-0'), isNull); expect(cache.obtener('doc-1'), isNotNull); expect(cache.obtener('doc-256'), isNotNull); }, ); test('obtener() en un miss devuelve null, sin lanzar', () { final cache = CacheMetadatosSesion(); expect(cache.obtener('doc-inexistente'), isNull); }); test('guardar() sobre una clave existente actualiza el valor', () { final cache = CacheMetadatosSesion(); cache.guardar('doc-1', const MetadatosPista(titulo: 'Original')); cache.guardar('doc-1', const MetadatosPista(titulo: 'Actualizado')); expect(cache.obtener('doc-1')?.titulo, 'Actualizado'); }); }); group('FuenteMusicaLocalAutoImpl.metadatosDe', () { const canal = MethodChannel('pluriwave/file_actions'); Future prefsConCarpeta() async { SharedPreferences.setMockInitialValues({ 'musica_local_uri': 'content://tree/primary:Music', }); return SharedPreferences.getInstance(); } tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, null); }); test( 'documentIds vacío devuelve {} sin invocar el canal', () async { var llamadas = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, (call) async { llamadas++; return >[]; }); final fuente = FuenteMusicaLocalAutoImpl(prefs: await prefsConCarpeta()); final resultado = await fuente.metadatosDe(const []); expect(resultado, isEmpty); expect(llamadas, 0); }, ); test( 'el canal lanzando una excepción degrada a {} en vez de propagar', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, (call) async { throw PlatformException(code: 'ERROR'); }); final fuente = FuenteMusicaLocalAutoImpl(prefs: await prefsConCarpeta()); final resultado = await fuente.metadatosDe(const ['doc-1']); expect(resultado, isEmpty); }, ); test( 'una fila nativa con campo null/faltante produce un MetadatosPista ' 'con ese campo null, sin lanzar', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, (call) async { expect(call.method, 'readAudioMetadataBatch'); return [ { 'documentId': 'doc-1', 'titulo': null, 'artista': 'Artista', 'bitrate': null, 'sampleRate': null, 'artUri': null, }, ]; }); final fuente = FuenteMusicaLocalAutoImpl(prefs: await prefsConCarpeta()); final resultado = await fuente.metadatosDe(const ['doc-1']); expect(resultado, hasLength(1)); expect(resultado['doc-1']?.titulo, isNull); expect(resultado['doc-1']?.artista, 'Artista'); expect(resultado['doc-1']?.bitrate, isNull); }, ); test( 'sin carpeta persistida devuelve {} sin invocar el canal', () async { SharedPreferences.setMockInitialValues({}); var llamadas = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, (call) async { llamadas++; return >[]; }); final fuente = FuenteMusicaLocalAutoImpl( prefs: await SharedPreferences.getInstance(), ); final resultado = await fuente.metadatosDe(const ['doc-1']); expect(resultado, isEmpty); expect(llamadas, 0); }, ); }); /// fix/android-auto-musica-local — «Muchísimas veces (la mayoría) no /// aparece la opción de reproducir música local, no aparece ni el menú». /// /// El usuario TIENE la compra PRO, así que no es un problema de /// entitlement. La causa real: `hasPersistedPermission` viaja por /// `MethodChannel('pluriwave/file_actions')`, cuyo ÚNICO registro de /// handler vive en `MainActivity.configureFlutterEngine`. Cuando Android /// Auto levanta el MediaBrowserService sin que la app se haya abierto, /// `audio_service` construye un FlutterEngine SIN Activity, ese método /// nunca corre, el canal se queda sin handler y `invokeMethod` lanza /// `MissingPluginException` — indistinguible hasta ahora de «permiso /// revocado». /// /// Estos tests fijan la distinción: «el canal no está disponible» NO es /// «no hay carpeta». group('FuenteMusicaLocalAutoImpl.estadoCarpeta', () { const canal = MethodChannel('pluriwave/file_actions'); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, null); }); test( 'con URI persistida y SIN handler nativo (motor headless de Android ' 'Auto) reporta canalNoDisponible, no noConfigurada', () async { SharedPreferences.setMockInitialValues({ 'musica_local_uri': 'content://tree/x', }); // Sin handler: `invokeMethod` lanza MissingPluginException, que es // exactamente lo que pasa en el motor sin Activity. TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, null); final fuente = FuenteMusicaLocalAutoImpl( prefs: await SharedPreferences.getInstance(), ); expect( await fuente.estadoCarpeta(), EstadoCarpetaLocal.canalNoDisponible, ); }, ); test( 'con URI persistida y handler nativo que responde false (permiso ' 'revocado de verdad) reporta noConfigurada', () async { SharedPreferences.setMockInitialValues({ 'musica_local_uri': 'content://tree/x', }); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, (call) async { expect(call.method, 'hasPersistedPermission'); return false; }); final fuente = FuenteMusicaLocalAutoImpl( prefs: await SharedPreferences.getInstance(), ); expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada); }, ); test( 'con URI persistida y handler nativo que responde true reporta ' 'configurada', () async { SharedPreferences.setMockInitialValues({ 'musica_local_uri': 'content://tree/x', }); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, (call) async => true); final fuente = FuenteMusicaLocalAutoImpl( prefs: await SharedPreferences.getInstance(), ); expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.configurada); }, ); test( 'sin URI persistida reporta noConfigurada sin invocar el canal', () async { SharedPreferences.setMockInitialValues({}); var llamadas = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, (call) async { llamadas++; return true; }); final fuente = FuenteMusicaLocalAutoImpl( prefs: await SharedPreferences.getInstance(), ); expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada); expect(llamadas, 0); }, ); test( 'un PlatformException del canal (el handler SÍ existe, la llamada ' 'falla) reporta noConfigurada, no canalNoDisponible', () async { SharedPreferences.setMockInitialValues({ 'musica_local_uri': 'content://tree/x', }); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(canal, (call) async { throw PlatformException(code: 'ERROR'); }); final fuente = FuenteMusicaLocalAutoImpl( prefs: await SharedPreferences.getInstance(), ); expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada); }, ); }); group('esArchivoAudio', () { test('acepta cualquier MIME audio/*, en cualquier capitalización', () { expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue); expect(esArchivoAudio('audio/flac', 'cancion.flac'), isTrue); expect(esArchivoAudio('AUDIO/OGG', 'cancion.ogg'), isTrue); }); test( 'rechaza MIME no-audio aunque el nombre tenga una extensión de ' 'audio (defensa contra un MIME nativo incorrecto)', () { expect(esArchivoAudio('video/mp4', 'cancion.mp3'), isFalse); expect( esArchivoAudio('application/octet-stream', 'cancion.mp3'), isFalse, ); expect(esArchivoAudio('text/plain', 'notas.mp3'), isFalse); }, ); test('rechaza MIME null, vacío o en blanco', () { expect(esArchivoAudio(null, 'cancion.mp3'), isFalse); expect(esArchivoAudio('', 'cancion.mp3'), isFalse); expect(esArchivoAudio(' ', 'cancion.mp3'), isFalse); }); test( 'rechaza nombre null, vacío o en blanco aunque el MIME sea audio ' 'válido', () { expect(esArchivoAudio('audio/mpeg', null), isFalse); expect(esArchivoAudio('audio/mpeg', ''), isFalse); expect(esArchivoAudio('audio/mpeg', ' '), isFalse); }, ); }); group('nombreCarpetaDesdeUri', () { const generico = 'Carpeta seleccionada'; test('almacenamiento interno: toma el segmento tras el último ":"', () { expect( nombreCarpetaDesdeUri( 'content://com.android.externalstorage.documents/tree/' 'primary%3AMusic%2FMyFolder', nombreGenerico: generico, ), 'MyFolder', ); }); test('tarjeta SD: toma el segmento tras el último ":"', () { expect( nombreCarpetaDesdeUri( 'content://com.android.externalstorage.documents/tree/' '1A2B-3C4D%3AMusic', nombreGenerico: generico, ), 'Music', ); }); test( 'carpeta anidada con "/": toma el segmento tras la última "/"', () { expect( nombreCarpetaDesdeUri( 'content://com.android.externalstorage.documents/tree/' 'primary%3AMusic%2FAlbum%2FDeep', nombreGenerico: generico, ), 'Deep', ); }, ); test( 'raíz de almacenamiento con tail vacío tras ":" cae al genérico', () { final resultado = nombreCarpetaDesdeUri( 'content://com.android.externalstorage.documents/tree/primary%3A', nombreGenerico: generico, ); expect(resultado, generico); }, ); test('URI no parseable o de forma inesperada cae al genérico', () { for (final uri in [ '', 'not a uri \$\$\$ ///', 'content://com.android.externalstorage.documents/tree/', ]) { final resultado = nombreCarpetaDesdeUri(uri, nombreGenerico: generico); expect(resultado, generico); } }); test( 'nunca devuelve la URI cruda ni una cadena vacía, para ningún caso ' 'de esta suite', () { final casos = [ 'content://com.android.externalstorage.documents/tree/' 'primary%3AMusic%2FMyFolder', 'content://com.android.externalstorage.documents/tree/' '1A2B-3C4D%3AMusic', 'content://com.android.externalstorage.documents/tree/primary%3A', '', 'garbage', ]; for (final uri in casos) { final resultado = nombreCarpetaDesdeUri(uri, nombreGenerico: generico); expect(resultado, isNotEmpty); expect(resultado, isNot(equals(uri))); expect(resultado, isNot(contains('content://'))); } }, ); }); }