diff --git a/android/app/src/main/res/drawable/ic_auto_eq_off.xml b/android/app/src/main/res/drawable/ic_auto_eq_off.xml new file mode 100644 index 0000000..34416fa --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_eq_off.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_auto_eq_on.xml b/android/app/src/main/res/drawable/ic_auto_eq_on.xml new file mode 100644 index 0000000..1584ea4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_eq_on.xml @@ -0,0 +1,3 @@ + + + diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 8d77b73..b649158 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "إصلاح", "alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.", - "alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك." + "alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.", + "autoEqDisableOption": "تعطيل" } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 7c170d0..11615c1 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "সমাধান করুন", "alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।", - "alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।" + "alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।", + "autoEqDisableOption": "বন্ধ করুন" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2c4bfd3..d972c03 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "Beheben", "alarmDiagnosticsIntentUnavailable": "Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.", - "alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen." + "alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen.", + "autoEqDisableOption": "Deaktivieren" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ccedce8..097fcca 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "Fix this", "alarmDiagnosticsIntentUnavailable": "Couldn't open that settings screen on this phone. Try looking for it manually in Settings.", - "alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet." + "alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet.", + "autoEqDisableOption": "Disable" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index cf54457..2e0f8c7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -833,5 +833,6 @@ }, "alarmDiagnosticsFixAction": "Solucionar", "alarmDiagnosticsIntentUnavailable": "No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.", - "alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma." + "alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma.", + "autoEqDisableOption": "Desactivar" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3e286a2..9267ffb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "Corriger", "alarmDiagnosticsIntentUnavailable": "Impossible d'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.", - "alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme." + "alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme.", + "autoEqDisableOption": "Désactiver" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9de8d9d..d08ec56 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "ठीक करें", "alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।", - "alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।" + "alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।", + "autoEqDisableOption": "बंद करें" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1fff03d..dcb8c90 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "Perbaiki", "alarmDiagnosticsIntentUnavailable": "Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.", - "alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu." + "alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu.", + "autoEqDisableOption": "Nonaktifkan" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 21d2cc9..aeb9ee9 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "Risolvi", "alarmDiagnosticsIntentUnavailable": "Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.", - "alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia." + "alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia.", + "autoEqDisableOption": "Disattiva" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 1031fc0..d0151af 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "修正する", "alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。", - "alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。" + "alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。", + "autoEqDisableOption": "無効化" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 155136c..b234893 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "Resolver", "alarmDiagnosticsIntentUnavailable": "Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.", - "alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme." + "alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme.", + "autoEqDisableOption": "Desativar" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3be5bf3..32e3f7b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "Исправить", "alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.", - "alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника." + "alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.", + "autoEqDisableOption": "Отключить" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 47af98e..0a5d154 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -874,5 +874,6 @@ }, "alarmDiagnosticsFixAction": "解决", "alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。", - "alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。" + "alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。", + "autoEqDisableOption": "关闭" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 1e97997..22bef5f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3187,6 +3187,12 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Todavía no pudimos revisar tus ajustes de alarma.'** String get alarmDiagnosticsUnavailableHint; + + /// No description provided for @autoEqDisableOption. + /// + /// In es, this message translates to: + /// **'Desactivar'** + String get autoEqDisableOption; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index bdc6149..40605e7 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1760,4 +1760,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.'; + + @override + String get autoEqDisableOption => 'تعطيل'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 54abf51..ae3f5aa 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1768,4 +1768,7 @@ class AppLocalizationsBn extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।'; + + @override + String get autoEqDisableOption => 'বন্ধ করুন'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 5da415c..3be5612 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1782,4 +1782,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'Wir konnten deine Alarmeinstellungen noch nicht prüfen.'; + + @override + String get autoEqDisableOption => 'Deaktivieren'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 9390c61..19433cc 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1762,4 +1762,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'We couldn\'t check your alarm settings yet.'; + + @override + String get autoEqDisableOption => 'Disable'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 4b1a268..1a201ea 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1778,4 +1778,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'Todavía no pudimos revisar tus ajustes de alarma.'; + + @override + String get autoEqDisableOption => 'Desactivar'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 9e7eea0..2c935cf 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1788,4 +1788,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'Nous n\'avons pas encore pu vérifier vos paramètres d\'alarme.'; + + @override + String get autoEqDisableOption => 'Désactiver'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index edae3ef..b8fd3de 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1763,4 +1763,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।'; + + @override + String get autoEqDisableOption => 'बंद करें'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index d7594bf..d8b2eca 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1772,4 +1772,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'Kami belum bisa memeriksa pengaturan alarmmu.'; + + @override + String get autoEqDisableOption => 'Nonaktifkan'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 7ca9c9b..4c8e8f8 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1786,4 +1786,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'Non abbiamo ancora potuto controllare le impostazioni della sveglia.'; + + @override + String get autoEqDisableOption => 'Disattiva'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 079753d..76b4d03 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1711,4 +1711,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'アラームの設定をまだ確認できていません。'; + + @override + String get autoEqDisableOption => '無効化'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index b2d5df9..9067f87 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1773,4 +1773,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'Ainda não conseguimos verificar as configurações do seu alarme.'; + + @override + String get autoEqDisableOption => 'Desativar'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index ea9c019..9559e89 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1778,4 +1778,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => 'Мы пока не смогли проверить настройки вашего будильника.'; + + @override + String get autoEqDisableOption => 'Отключить'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 1a0c678..71638d3 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1699,4 +1699,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get alarmDiagnosticsUnavailableHint => '我们还无法检查你的闹钟设置。'; + + @override + String get autoEqDisableOption => '关闭'; } diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 46f53b7..b2ad3d8 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -10,6 +10,7 @@ import '../estado/orden_emisoras.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; +import '../modelos/preset_ecualizador.dart'; import 'musica_local_auto.dart'; import 'persistencia_tolerante.dart'; import 'servicio_favoritos.dart'; @@ -19,16 +20,22 @@ import 'servicio_favoritos.dart'; /// no [NodoLocal] coupling — so a future paged folder type can reuse the /// slice arithmetic directly. An empty [items] or a [pagina] beyond the /// list's range returns `[]`, never throws. -List paginaDe(List items, {required int pagina, required int tamano}) => - items.skip(pagina * tamano).take(tamano).toList(); +List paginaDe( + List items, { + required int pagina, + required int tamano, +}) => items.skip(pagina * tamano).take(tamano).toList(); /// Whether a page after [pagina] exists for a list of [total] elements /// (Design ADR-6): `true` iff at least one element remains beyond the /// current page's slice. The exact-boundary case /// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to /// reveal. -bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) => - total > (pagina + 1) * tamano; +bool hayPaginaSiguiente( + int total, { + required int pagina, + required int tamano, +}) => total > (pagina + 1) * tamano; /// Browse-tree ordering comparator for a local-music folder's children /// (Design "Directories before files", item 1): directories sort before @@ -83,8 +90,7 @@ bool faviconUsable(String? favicon) { // even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is // `true`) — check `host.isNotEmpty` explicitly to actually require a // non-empty authority host. - return (uri.scheme == 'http' || uri.scheme == 'https') && - uri.host.isNotEmpty; + return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty; } /// Deterministic rotation index over the 4 on-brand fallback arts, same @@ -98,10 +104,11 @@ int indiceArtePara(String seed) => /// drawable URI selected via [indiceArtePara] over `e.uuid` — the same /// on-brand art the phone UI would pick for this station (per-station /// parity), never a launcher-icon lookalike. -String artUriPara(Emisora e) => faviconUsable(e.favicon) - ? e.favicon! - : 'android.resource://es.freetimelab.pluriwave/drawable/' - 'station_art_${_nombresArte[indiceArtePara(e.uuid)]}'; +String artUriPara(Emisora e) => + faviconUsable(e.favicon) + ? e.favicon! + : 'android.resource://es.freetimelab.pluriwave/drawable/' + 'station_art_${_nombresArte[indiceArtePara(e.uuid)]}'; /// Formats a human-readable audio-quality hint for the browse row's /// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"): @@ -212,6 +219,17 @@ class ConstructorArbolAuto { /// (Design "Local root hidden until a folder is configured"). static const idMusicaLocal = 'musica_local'; + /// Root folder id for the "Ecualizador" browsable folder (decision + /// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory + /// presets, the currently-active one marked. Deliberately NOT added to + /// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated + /// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart` + /// (which needs `AppLocalizations` -- this pure builder class does not + /// depend on it), not the generic station-list [hijos] path. Unlike + /// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally + /// hidden. + static const idEcualizador = 'ecualizador'; + static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras}; static const _maxItemsPorCarpeta = 50; @@ -295,12 +313,23 @@ class ConstructorArbolAuto { }; /// The root folders (Favoritos, Todas las emisoras, Mis emisoras, - /// optionally Música Local), all non-playable. + /// optionally Música Local, Ecualizador), all non-playable. /// - /// There is deliberately no equalizer folder: EQ is configured on the phone - /// only. The car still gets the right sound, because the per-device preset - /// is applied automatically when the output device changes — that lives in - /// `EstadoEcualizador`, not in this tree. + /// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer + /// folder" rule that used to live in this doc comment (commit `2403da3`, + /// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4 + /// line 40). That rule was sound when written, but predated on-device + /// feedback showing that Android Auto custom actions don't surface + /// enough state for choosing among six presets: a monochrome icon cannot + /// legibly encode "which preset", and many head units render a custom + /// action icon-first, hiding its label. `Ecualizador` is a real + /// browsable folder again: "Desactivar" first, then the six factory + /// presets, the active one marked (children built by + /// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays + /// free of any `AppLocalizations` dependency, unlike that builder). + /// Always present, and LAST in the list (after Música Local, when + /// included) -- unlike [idMusicaLocal] it is never conditionally hidden. + /// Do not "restore" the no-folder rule without re-reading that decision. /// /// `Música Local` is OMITTED entirely (not just empty) unless /// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder @@ -311,6 +340,7 @@ class ConstructorArbolAuto { _carpeta(idTodas, 'Todas las emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'), if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), + _carpeta(idEcualizador, 'Ecualizador'), ]; MediaItem _carpeta(String id, String titulo) => MediaItem( @@ -457,10 +487,11 @@ class ConstructorArbolAuto { final construir = construirItem ?? _itemLocal; final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion); final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano); - final docIds = paginaActual - .where((n) => !n.esDirectorio) - .map((n) => n.documentId) - .toList(); + final docIds = + paginaActual + .where((n) => !n.esDirectorio) + .map((n) => n.documentId) + .toList(); final metadatos = await metadatosDe(docIds); final items = paginaActual.map((n) => construir(n, metadatos)).toList(); if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) { @@ -486,8 +517,7 @@ class ConstructorArbolAuto { // (direct or nested), absent for a folder that is genuinely empty // even recursively (Spec "Folder has no tracks"). if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre), - if (hayContenidoReproducible) - _itemReproducirAleatorio(documentIdPadre), + if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre), if (ofreceOrdenCalidad(totalPistas)) _itemModoOrdenCalidad(documentIdPadre), if (ofreceBuckets(totalPistas)) @@ -725,10 +755,11 @@ class ConstructorArbolAuto { final ordenados = [...buckets[idxBucket].nodos] ..sort((a, b) => a.nombre.compareTo(b.nombre)); final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano); - final docIds = paginaActual - .where((n) => !n.esDirectorio) - .map((n) => n.documentId) - .toList(); + final docIds = + paginaActual + .where((n) => !n.esDirectorio) + .map((n) => n.documentId) + .toList(); final metadatos = await metadatosDe(docIds); final items = paginaActual.map((n) => construir(n, metadatos)).toList(); if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) { @@ -743,20 +774,21 @@ class ConstructorArbolAuto { } final meta = metadatos[nodo.documentId]; final tituloMeta = meta?.titulo?.trim(); - final titulo = (tituloMeta != null && tituloMeta.isNotEmpty) - ? tituloMeta - : _tituloDesdeNombre(nodo.nombre); + final titulo = + (tituloMeta != null && tituloMeta.isNotEmpty) + ? tituloMeta + : _tituloDesdeNombre(nodo.nombre); final artUriMeta = meta?.artUri?.trim(); - final artUri = (artUriMeta != null && artUriMeta.isNotEmpty) - ? artUriMeta - : artUriLocal(nodo.documentId); + final artUri = + (artUriMeta != null && artUriMeta.isNotEmpty) + ? artUriMeta + : artUriLocal(nodo.documentId); final artistaMeta = meta?.artista?.trim(); return MediaItem( id: '$_prefijoPista${nodo.documentId}', title: titulo, - artist: (artistaMeta != null && artistaMeta.isNotEmpty) - ? artistaMeta - : null, + artist: + (artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null, playable: true, artUri: Uri.parse(artUri), displaySubtitle: subtituloCalidadLocal(meta), @@ -777,15 +809,17 @@ class ConstructorArbolAuto { required List grupos, required List favoritos, }) { - final carpetas = grupos - .where((g) => !g.esSinAsignar) - .where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id)) - .take(_maxGruposPorFavoritos) - .map(itemGrupo) - .toList(); - final sinAsignar = favoritos - .where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId) - .toList(); + final carpetas = + grupos + .where((g) => !g.esSinAsignar) + .where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id)) + .take(_maxGruposPorFavoritos) + .map(itemGrupo) + .toList(); + final sinAsignar = + favoritos + .where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId) + .toList(); return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)]; } @@ -801,13 +835,62 @@ class ConstructorArbolAuto { if (!esCarpetaGrupo(grupoMediaId)) return const []; final id = grupoMediaId.substring(_prefijoGrupo.length); if (id.isEmpty) return const []; - final miembros = favoritos - .where((e) => e.grupoFavoritosId == id) - .toList(); + final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList(); if (miembros.isEmpty) return const []; final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad); return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); } + + /// Equalizer preset-selection media-id prefix (decision + /// `auto/ecualizador-diseno`): `eq_preset:` for the six + /// factory presets, plus the reserved [_valorDesactivarEq] sentinel for + /// the "Desactivar" item ([idDesactivarEq]). Collision-free against every + /// other prefix/bare id in this class -- diverges from every sibling + /// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every + /// bare folder id (none of which starts with "eq_preset:"). + static const _prefijoPresetEq = 'eq_preset:'; + + /// Reserved sentinel raw value for the "Desactivar" item under + /// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never + /// collides with a real [PresetEcualizador.nombre]; none of the six + /// factory presets is named this. + static const _valorDesactivarEq = '_off_'; + + /// The "Desactivar" item's media id: the reserved [_valorDesactivarEq] + /// sentinel under [_prefijoPresetEq]. + static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq'; + + /// Whether [id] identifies an item under the Ecualizador folder (a + /// factory preset OR "Desactivar"). + bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq); + + /// Whether [id] is specifically the "Desactivar" item (not a factory + /// preset). Only meaningful alongside [esPresetEqMediaId]. + bool esDesactivarEqMediaId(String id) => id == idDesactivarEq; + + /// Builds a factory preset's selection media id, matched by raw + /// (untranslated) [PresetEcualizador.nombre] -- the SAME identity + /// [PresetEcualizador.presets] already uses for equality, so a locale + /// change never breaks resolution. + String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset'; + + /// Resolves an `eq_preset:` [id] to the matching factory + /// [PresetEcualizador] from [presets] (defaults to + /// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns + /// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or + /// any id that doesn't match [esPresetEqMediaId] -- never throws. + PresetEcualizador? resolverPresetEq( + String id, { + List? presets, + }) { + if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null; + final nombre = id.substring(_prefijoPresetEq.length); + final lista = presets ?? PresetEcualizador.presets; + for (final preset in lista) { + if (preset.nombre == nombre) return preset; + } + return null; + } } /// Routing seam between a car-tapped `emisora:` media id and the @@ -847,6 +930,43 @@ Future reproducirPorMediaId( await reproducir(item); } +/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision +/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam +/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a +/// resolved factory preset to [aplicarPreset] -- turning the equalizer +/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo] +/// is currently `false`, so tapping a preset while the equalizer is off +/// both re-enables it AND applies the tapped preset's gains (Spec +/// "selecting a preset while disabled enables it and applies it"), never +/// silently just remembering the preset for later. [aplicarPreset] runs +/// BEFORE the enable check so the native engine only ever pushes gains +/// once, for the NEW preset -- never once for whatever was active before, +/// then again for the new one. +/// +/// A stale/unresolvable id, or any id that doesn't match +/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback +/// runs and no exception propagates. +Future seleccionarPresetEqPorMediaId( + String id, { + required bool activo, + required Future Function(PresetEcualizador) aplicarPreset, + required Future Function(bool) activarEcualizador, +}) async { + final constructor = ConstructorArbolAuto(); + if (!constructor.esPresetEqMediaId(id)) return; + + if (constructor.esDesactivarEqMediaId(id)) { + await activarEcualizador(false); + return; + } + + final preset = constructor.resolverPresetEq(id); + if (preset == null) return; + + await aplicarPreset(preset); + if (!activo) await activarEcualizador(true); +} + /// Fallback title (Design "Title = filename minus extension") for a blank /// or otherwise empty-after-stripping local filename — hardcoded Spanish, /// matching every other car-tree label in this file (`'Favoritos'`, @@ -904,10 +1024,8 @@ List ordenarPorCalidadLocal( ) { final ordenados = List.from(nodos); ordenados.sort( - (a, b) => compararCalidadLocal( - metadatos[a.documentId], - metadatos[b.documentId], - ), + (a, b) => + compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]), ); return ordenados; } @@ -948,12 +1066,13 @@ List bucketsDe(List nodos) { final pistas = nodos.where((n) => !n.esDirectorio).toList(); return _rangosBucket.map((rango) { final (etiqueta, desde, hasta) = rango; - final coincidencias = pistas.where((n) { - final recortado = n.nombre.trim(); - if (recortado.isEmpty) return false; - final letra = recortado[0].toLowerCase(); - return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0; - }).toList(); + final coincidencias = + pistas.where((n) { + final recortado = n.nombre.trim(); + if (recortado.isEmpty) return false; + final letra = recortado[0].toLowerCase(); + return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0; + }).toList(); return BucketLocal(etiqueta: etiqueta, nodos: coincidencias); }).toList(); } @@ -1119,9 +1238,10 @@ Future reproducirCarpetaLocal( } final recolectadas = await pistasRecursivas(documentId, fuente: fuente); - final pistas = aleatorio - ? mezclarFisherYates(recolectadas, rng ?? Random()) - : recolectadas; + final pistas = + aleatorio + ? mezclarFisherYates(recolectadas, rng ?? Random()) + : recolectadas; if (pistas.isEmpty) return; await iniciarCola(pistas); diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 7f53582..e9ef72b 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -66,7 +66,10 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) { /// the browse tree and the car-tap path already show, instead of a blank /// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler] /// dependency — so it is unit-testable without instantiating the handler. -MediaItem mediaItemParaEmisora(Emisora emisora, {required AppLocalizations l10n}) { +MediaItem mediaItemParaEmisora( + Emisora emisora, { + required AppLocalizations l10n, +}) { return MediaItem( id: emisora.url, title: localizedStationName(l10n, emisora.nombre), @@ -108,7 +111,6 @@ Emisora emisoraDesdeMediaItem(MediaItem mediaItem) { /// `MediaControl`/`customAction` namespace, never compared against a /// media id). const accionEqToggle = 'eq_toggle'; -const accionEqPresetSiguiente = 'eq_preset_siguiente'; /// Advances to the NEXT factory preset after [actual] in [presets] order /// (Design "EQ custom actions — cycling presets", item 4): wraps around @@ -151,38 +153,126 @@ String nombrePresetVisible(AppLocalizations l10n, String nombre) { } /// Builds the equalizer's custom-action `MediaControl`s for the now-playing -/// screen (Design "EQ custom actions", item 4) — deliberately just 2: an -/// on/off toggle plus a cycling-preset action, NOT one action per preset, -/// since Android Auto only surfaces a limited number of custom actions. +/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off +/// toggle. The previous design paired this with a SECOND action that cycled +/// through the six factory presets; that action is REMOVED. On-device +/// feedback: many head units render custom actions icon-first, so two +/// static, non-parametrized glyphs sitting side by side looked identical/ +/// dead even though the toggle's own icon DID change and the cycle action +/// DID work — a monochrome icon simply cannot legibly encode "which of six +/// presets" the way a browsable list's text rows can. Preset selection now +/// lives in the "Ecualizador" browsable folder instead (see +/// [itemsEcualizadorAuto]), which also frees this scarce custom-action +/// slot. Do NOT re-add a preset-cycling custom action; extend the folder +/// instead. /// Empty when [disponible] is false (gate on EQ availability, mirrors the /// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device /// without the native Equalizer effect gets no EQ actions at all, not -/// broken ones. Reuses the SAME bundled `ic_stat_pluriwave` drawable the -/// notification's own status-bar icon already uses (an equalizer-bars -/// glyph) — zero new native assets. Pure, no handler dependency. +/// broken ones. +/// +/// On-device feedback follow-up: this action used to reuse the SAME +/// `ic_stat_pluriwave` drawable as everything else and was visually +/// indistinguishable on a car head unit, which foregrounds the icon over +/// the label. It now gets its own dedicated drawables +/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects +/// [activo] (not just its label) so on/off is legible at a glance. Pure, no +/// handler dependency. List controlesEcualizadorPersonalizados({ required bool disponible, required bool activo, - required PresetEcualizador presetActual, required AppLocalizations l10n, }) { if (!disponible) return const []; return [ MediaControl.custom( - androidIcon: 'drawable/ic_stat_pluriwave', - label: activo - ? l10n.eqCustomActionDisableLabel - : l10n.eqCustomActionEnableLabel, + androidIcon: + activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off', + label: + activo + ? l10n.eqCustomActionDisableLabel + : l10n.eqCustomActionEnableLabel, name: accionEqToggle, ), - MediaControl.custom( - androidIcon: 'drawable/ic_stat_pluriwave', - label: l10n.eqCustomActionPresetLabel( - nombrePresetVisible(l10n, presetActual.nombre), - ), - name: accionEqPresetSiguiente, + ]; +} + +/// Content-style extras for the Ecualizador folder's items (decision +/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto +/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than +/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces +/// are deliberately duplicated across this handler/service layer and the +/// pure browse-tree builder layer rather than cross-layer-shared). List +/// style, not grid: these items are short text options with no artwork of +/// their own, unlike a station or local-track tile. +const _contentStyleListaEq = { + 'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1, + 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1, +}; + +/// Marks the active Ecualizador-folder item by prefixing [titulo] with a +/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active +/// preset must be visibly marked"). +/// +/// A `MediaItem.extras` completion-status flag (`androidx.media.utils. +/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered +/// and REJECTED as the marking mechanism: this project's `audio_service` +/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle` +/// 's list/grid hints are exposed — and the raw platform key itself is +/// designed for playback-COMPLETION tracking (e.g. "this podcast episode +/// was already listened to"), not item SELECTION; repurposing it here could +/// render as "already played" on some head units, which would be actively +/// misleading for a preset picker, and there is no way to verify its actual +/// rendering on a real head unit from this environment. A plain-text +/// marker renders identically and unambiguously on every head unit, which +/// an unverifiable, semantically-mismatched extras key cannot guarantee. +String _marcarActivoEq(String titulo, {required bool activo}) => + activo ? '✓ $titulo' : titulo; + +/// Builds the "Ecualizador" folder's children for the Android Auto browse +/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the +/// six factory presets in [PresetEcualizador.presets] order, each localized +/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action +/// label already uses, so a preset's name reads identically whether the +/// driver sees it in the now-playing screen's tooltip or in this folder. +/// All items are playable: tapping one is dispatched through +/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto. +/// dart`), the same seam every other browse-tree leaf already uses; this +/// folder has no sub-browsing. Exactly one item is marked active via +/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise +/// whichever preset equals [presetActual] — never both at once, and never +/// zero once this function is reached (an unresolvable [presetActual] with +/// [activo] `true` simply marks nothing, which cannot happen in practice +/// since [presetActual] always originates from [PresetEcualizador.presets] +/// or a "Personalizado" tweak that would just leave every item unmarked +/// rather than mis-marking one). +List itemsEcualizadorAuto({ + required bool activo, + required PresetEcualizador presetActual, + required AppLocalizations l10n, +}) { + final constructor = ConstructorArbolAuto(); + final items = [ + MediaItem( + id: ConstructorArbolAuto.idDesactivarEq, + title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo), + playable: true, + extras: _contentStyleListaEq, ), ]; + for (final preset in PresetEcualizador.presets) { + items.add( + MediaItem( + id: constructor.idPresetEq(preset.nombre), + title: _marcarActivoEq( + nombrePresetVisible(l10n, preset.nombre), + activo: activo && preset == presetActual, + ), + playable: true, + extras: _contentStyleListaEq, + ), + ); + } + return items; } /// Wrapper de alto nivel para el UI. @@ -473,7 +563,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler controlesEcualizadorPersonalizados( disponible: _eqDisponible, activo: _ecualizadorActivo, - presetActual: _presetActual, l10n: _textos, ); @@ -1081,14 +1170,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler await _reproducirEntradaCola(anterior.actual); } - /// Dispatches the equalizer's 2 custom actions (item 4, Design "EQ custom - /// actions"): `accionEqToggle` flips on/off, `accionEqPresetSiguiente` - /// cycles to the next factory preset. Both delegate to the existing - /// [setEcualizadorActivo]/[aplicarPreset] — the SAME entry points the + /// Dispatches the equalizer's only custom action (decision + /// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating + /// to the existing [setEcualizadorActivo] — the SAME entry point the /// phone settings screen uses via `EstadoEcualizador` — so a car tap and a - /// phone tap have identical effects and both refresh the custom action's - /// label via `_actualizarControlesEq()` (already wired into those two - /// methods). Any other [name] is a no-op — never throws. + /// phone tap have identical effects and both refresh the action's label + /// via `_actualizarControlesEq()` (already wired into that method). The + /// preset-cycling action that used to live here is REMOVED — preset + /// selection now goes through the "Ecualizador" browsable folder (see + /// `seleccionarPresetEqPorMediaId` in `navegacion_auto.dart`, dispatched + /// from [playFromMediaId] below). Any other [name] is a no-op — never + /// throws. @override Future customAction( String name, [ @@ -1097,8 +1189,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler switch (name) { case accionEqToggle: await setEcualizadorActivo(!_ecualizadorActivo); - case accionEqPresetSiguiente: - await aplicarPreset(presetSiguiente(_presetActual)); } } @@ -1145,6 +1235,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler fuente: fuenteLocal, ); if (musicaLocal != null) return musicaLocal; + // Ecualizador folder (decision `auto/ecualizador-diseno`): needs no + // external data source, unlike every branch below it -- checked + // before the `_fuenteNavegacionGlobal` gate, mirroring how the + // local-music branch above is also resolved before that gate. + if (parentMediaId == ConstructorArbolAuto.idEcualizador) { + return itemsEcualizadorAuto( + activo: _ecualizadorActivo, + presetActual: _presetActual, + l10n: _textos, + ); + } final fuente = _fuenteNavegacionGlobal; if (fuente == null) return const []; if (parentMediaId == ConstructorArbolAuto.idFavoritos) { @@ -1215,14 +1316,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler if (fuenteLocal == null) return; await reproducirCarpetaLocal( mediaId, - aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId( - mediaId, - ), + aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId), fuente: fuenteLocal, iniciarCola: _iniciarColaLocal, ); return; } + // Equalizer preset selection (decision `auto/ecualizador-diseno`): + // THIRD branch, same unconditional-return shape as the two above -- + // an `eq_preset:` id never falls through to station routing. + if (constructorArbol.esPresetEqMediaId(mediaId)) { + await seleccionarPresetEqPorMediaId( + mediaId, + activo: _ecualizadorActivo, + aplicarPreset: aplicarPreset, + activarEcualizador: setEcualizadorActivo, + ); + return; + } final fuente = _fuenteNavegacionGlobal; if (fuente == null) return; await reproducirPorMediaId( diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 4f19239..674452a 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -5,33 +5,38 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/pista_local.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/musica_local_auto.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 = { - 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, - }; + test( + 'rechaza favicons malformados o no http(s), acepta http(s) válidos', + () { + final casos = { + 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', - ); - }); - }); + casos.forEach((entrada, esperado) { + expect( + faviconUsable(entrada), + esperado, + reason: + 'faviconUsable(${entrada.toString()}) debería ser $esperado', + ); + }); + }, + ); }); group('indiceArtePara', () { @@ -39,8 +44,7 @@ void main() { const seeds = ['', 'a', 'uuid-1234-abcd-real-looking']; for (final seed in seeds) { - final esperado = - seed.codeUnits.fold(0, (a, b) => a + b) % 4; + final esperado = seed.codeUnits.fold(0, (a, b) => a + b) % 4; expect(indiceArtePara(seed), esperado); } }); @@ -143,27 +147,39 @@ void main() { group('subtituloCalidad', () { test('matriz de codec/bitrate conocidos y desconocidos', () { expect( - subtituloCalidad(_emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128)), + subtituloCalidad( + _emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128), + ), '128 kbps · MP3', ); expect( - subtituloCalidad(_emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128)), + subtituloCalidad( + _emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128), + ), '128 kbps', ); expect( - subtituloCalidad(_emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null)), + subtituloCalidad( + _emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null), + ), 'MP3', ); expect( - subtituloCalidad(_emisora(uuid: 'u4', nombre: 'N', codec: 'mp3', bitrate: 0)), + subtituloCalidad( + _emisora(uuid: 'u4', nombre: 'N', codec: 'mp3', bitrate: 0), + ), 'MP3', ); expect( - subtituloCalidad(_emisora(uuid: 'u5', nombre: 'N', codec: null, bitrate: null)), + subtituloCalidad( + _emisora(uuid: 'u5', nombre: 'N', codec: null, bitrate: null), + ), isNull, ); expect( - subtituloCalidad(_emisora(uuid: 'u6', nombre: 'N', codec: null, bitrate: 0)), + subtituloCalidad( + _emisora(uuid: 'u6', nombre: 'N', codec: null, bitrate: 0), + ), isNull, ); expect( @@ -222,253 +238,395 @@ void main() { }); group('ConstructorArbolAuto.raiz', () { - test( - 'con incluirMusicaLocal: true devuelve exactamente 4 carpetas no ' - 'reproducibles con los ids esperados, terminando en Música Local', - () { - final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true); + test('con incluirMusicaLocal: true devuelve exactamente 5 carpetas no ' + 'reproducibles con los ids esperados, terminando en Ecualizador', () { + final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true); - expect(raiz, hasLength(4)); - final ids = raiz.map((item) => item.id).toSet(); - expect( - ids, - equals({ - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idMusicaLocal, - }), - ); - for (final item in raiz) { - expect(item.playable, isFalse); - expect(item.title, isNotEmpty); - } - expect(raiz.last.id, ConstructorArbolAuto.idMusicaLocal); - }, - ); - - test( - 'con incluirMusicaLocal: false devuelve exactamente 3 carpetas — ' - 'Música Local queda OCULTA, no vacía', - () { - final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false); - - expect(raiz, hasLength(3)); - final ids = raiz.map((item) => item.id).toSet(); - expect( - ids, - equals({ - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - }), - ); - expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal))); - }, - ); - - test('la raíz no ofrece ninguna carpeta de ecualizador', () { - // EQ is phone-only: the car never browses or edits it. The right preset - // still reaches the car, applied automatically by EstadoEcualizador when - // the output device changes. - final ids = ConstructorArbolAuto() - .raiz(incluirMusicaLocal: true) - .map((item) => item.id) - .toList(); - - expect(ids, isNot(contains('ecualizador'))); - expect(ids, isNot(contains('eq_custom'))); - }); - }); - - group('esCarpetaLocalMediaId / esPistaMediaId', () { - test( - 'reconocen sus prefijos, rechazan el resto y no colisionan entre sí ' - 'ni con emisora:/grupo:/los ids de carpeta fijos', - () { - final builder = ConstructorArbolAuto(); - - expect(builder.esCarpetaLocalMediaId('carpeta_local:doc1'), isTrue); - expect(builder.esCarpetaLocalMediaId('carpeta_local:'), isTrue); - expect(esPistaMediaId('pista:doc1'), isTrue); - expect(esPistaMediaId('pista:'), isTrue); - - final noLocales = [ - 'emisora:x', - 'grupo:g1', + expect(raiz, hasLength(5)); + final ids = raiz.map((item) => item.id).toSet(); + expect( + ids, + equals({ ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idMusicaLocal, - '', - ]; - for (final id in noLocales) { - expect( - builder.esCarpetaLocalMediaId(id), - isFalse, - reason: 'esCarpetaLocalMediaId($id) debería ser false', - ); - expect( - esPistaMediaId(id), - isFalse, - reason: 'esPistaMediaId($id) debería ser false', - ); - } + ConstructorArbolAuto.idEcualizador, + }), + ); + for (final item in raiz) { + expect(item.playable, isFalse); + expect(item.title, isNotEmpty); + } + // Decision `auto/ecualizador-diseno`: Ecualizador is always LAST, + // after Música Local when it is present. + expect(raiz[raiz.length - 2].id, ConstructorArbolAuto.idMusicaLocal); + expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); + }); - // carpeta_local: y pista: nunca colisionan entre sí. - expect(builder.esCarpetaLocalMediaId('pista:doc1'), isFalse); - expect(esPistaMediaId('carpeta_local:doc1'), isFalse); - }, - ); + test('con incluirMusicaLocal: false devuelve exactamente 4 carpetas — ' + 'Música Local queda OCULTA, no vacía, y Ecualizador sigue presente y ' + 'al final', () { + final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false); + + expect(raiz, hasLength(4)); + final ids = raiz.map((item) => item.id).toSet(); + expect( + ids, + equals({ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idEcualizador, + }), + ); + expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal))); + expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); + }); + + test('la raíz SÍ ofrece la carpeta de ecualizador -- decisión ' + '`auto/ecualizador-diseno` SUPERSEDE la regla anterior de "sin ' + 'carpeta de ecualizador" (commit `2403da3`); este test REEMPLAZA ' + 'deliberadamente al test homónimo previo que afirmaba lo contrario, ' + 'no es una regresión', () { + final ids = + ConstructorArbolAuto() + .raiz(incluirMusicaLocal: true) + .map((item) => item.id) + .toList(); + + expect(ids, contains(ConstructorArbolAuto.idEcualizador)); + expect(ConstructorArbolAuto.idEcualizador, 'ecualizador'); + }); + }); + + group('ConstructorArbolAuto EQ preset media-ids (decision ' + '`auto/ecualizador-diseno`)', () { + test('esPresetEqMediaId reconoce el prefijo eq_preset: (presets y ' + 'Desactivar), no colisiona con emisora:/grupo:/carpeta_local:/' + 'pista:/los ids de carpeta fijos', () { + final builder = ConstructorArbolAuto(); + + expect(builder.esPresetEqMediaId('eq_preset:Rock'), isTrue); + expect( + builder.esPresetEqMediaId(ConstructorArbolAuto.idDesactivarEq), + isTrue, + ); + + final otros = [ + 'emisora:x', + 'grupo:g1', + 'carpeta_local:doc1', + 'carpeta_local_pag:0:doc1', + 'pista:doc1', + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + ConstructorArbolAuto.idEcualizador, + '', + ]; + for (final id in otros) { + expect( + builder.esPresetEqMediaId(id), + isFalse, + reason: 'esPresetEqMediaId($id) debería ser false', + ); + } + }); + + test('esDesactivarEqMediaId SOLO reconoce el sentinel de Desactivar, ' + 'nunca un preset real', () { + final builder = ConstructorArbolAuto(); + + expect( + builder.esDesactivarEqMediaId(ConstructorArbolAuto.idDesactivarEq), + isTrue, + ); + for (final preset in PresetEcualizador.presets) { + expect( + builder.esDesactivarEqMediaId(builder.idPresetEq(preset.nombre)), + isFalse, + reason: + 'esDesactivarEqMediaId no debería reconocer un preset ' + 'real (${preset.nombre})', + ); + } + }); test( - 'idCarpetaLocalDesde recorta por longitud, preservando un documentId ' - 'que contiene ":" (SAF documentIds reales, p.ej. ' - '"primary:Music/Local")', + 'idPresetEq construye eq_preset: y resolverPresetEq ' + 'resuelve cada uno de los 6 presets de fábrica por su nombre crudo', () { final builder = ConstructorArbolAuto(); - const docId = 'primary:Music/Local'; - expect(builder.idCarpetaLocalDesde('carpeta_local:$docId'), docId); + for (final preset in PresetEcualizador.presets) { + final id = builder.idPresetEq(preset.nombre); + expect(id, 'eq_preset:${preset.nombre}'); + expect(builder.resolverPresetEq(id), preset); + } }, ); + + test('resolverPresetEq devuelve null para el sentinel de Desactivar, un ' + 'nombre desconocido, o un id que no matchea esPresetEqMediaId -- ' + 'nunca lanza', () { + final builder = ConstructorArbolAuto(); + + expect( + builder.resolverPresetEq(ConstructorArbolAuto.idDesactivarEq), + isNull, + ); + expect(builder.resolverPresetEq('eq_preset:Preset Inexistente'), isNull); + expect(builder.resolverPresetEq('emisora:x'), isNull); + expect(builder.resolverPresetEq(''), isNull); + }); + + test('idDesactivarEq es estable y vive bajo el mismo prefijo ' + 'eq_preset:', () { + expect(ConstructorArbolAuto.idDesactivarEq, startsWith('eq_preset:')); + }); }); group( - 'esCarpetaLocalReproducirMediaId / esCarpetaLocalAleatorioMediaId ' - '(Design ADR-5, Phase 3)', + 'seleccionarPresetEqPorMediaId (decision `auto/ecualizador-diseno`)', () { test( - 'reconocen sus prefijos, no colisionan entre sí ni con NINGUNO de ' - 'los 8 prefijos/ids existentes (Spec "New Action Media-IDs Are ' - 'Collision-Free")', - () { - final builder = ConstructorArbolAuto(); + 'Desactivar llama activarEcualizador(false), nunca aplicarPreset', + () async { + final activarLlamadoCon = []; + var aplicarLlamado = false; - expect( - builder.esCarpetaLocalReproducirMediaId( - 'carpeta_local_reproducir:doc1', - ), - isTrue, - ); - expect( - builder.esCarpetaLocalAleatorioMediaId( - 'carpeta_local_aleatorio:doc1', - ), - isTrue, + await seleccionarPresetEqPorMediaId( + ConstructorArbolAuto.idDesactivarEq, + activo: true, + aplicarPreset: (_) async => aplicarLlamado = true, + activarEcualizador: (v) async => activarLlamadoCon.add(v), ); - final existentes = [ - 'emisora:x', - 'grupo:g1', - 'carpeta_local:doc1', - 'carpeta_local_pag:0:doc1', - 'carpeta_local_ord:calidad:0:doc1', - 'carpeta_local_bucket:0:0:doc1', - 'pista:doc1', - ]; - for (final id in existentes) { - expect( - builder.esCarpetaLocalReproducirMediaId(id), - isFalse, - reason: 'esCarpetaLocalReproducirMediaId($id) debería ser ' - 'false', - ); - expect( - builder.esCarpetaLocalAleatorioMediaId(id), - isFalse, - reason: 'esCarpetaLocalAleatorioMediaId($id) debería ser ' - 'false', + expect(activarLlamadoCon, [false]); + expect(aplicarLlamado, isFalse); + }, + ); + + test('seleccionar un preset mientras el ecualizador está DESACTIVADO lo ' + 'activa Y aplica ese preset (spec "selecting a preset while ' + 'disabled enables it and applies it")', () async { + PresetEcualizador? presetAplicado; + final activarLlamadoCon = []; + final builder = ConstructorArbolAuto(); + + await seleccionarPresetEqPorMediaId( + builder.idPresetEq(PresetEcualizador.rock.nombre), + activo: false, + aplicarPreset: (p) async => presetAplicado = p, + activarEcualizador: (v) async => activarLlamadoCon.add(v), + ); + + expect(presetAplicado, PresetEcualizador.rock); + expect(activarLlamadoCon, [true]); + }); + + test('seleccionar un preset mientras el ecualizador YA está activo solo ' + 'aplica el preset, sin volver a tocar activarEcualizador', () async { + PresetEcualizador? presetAplicado; + final activarLlamadoCon = []; + final builder = ConstructorArbolAuto(); + + await seleccionarPresetEqPorMediaId( + builder.idPresetEq(PresetEcualizador.jazz.nombre), + activo: true, + aplicarPreset: (p) async => presetAplicado = p, + activarEcualizador: (v) async => activarLlamadoCon.add(v), + ); + + expect(presetAplicado, PresetEcualizador.jazz); + expect(activarLlamadoCon, isEmpty); + }); + + test( + 'un id desconocido o que no matchea el prefijo eq_preset: es un ' + 'no-op -- ninguno de los dos callbacks se invoca, sin lanzar', + () async { + var llamadas = 0; + + for (final id in ['emisora:x', 'eq_preset:Preset Inexistente', '']) { + await seleccionarPresetEqPorMediaId( + id, + activo: true, + aplicarPreset: (_) async => llamadas++, + activarEcualizador: (_) async => llamadas++, ); } - // Y en la dirección inversa: ninguno de los 8 predicados - // existentes reconoce las 2 nuevas acciones. - const reproducir = 'carpeta_local_reproducir:doc1'; - const aleatorio = 'carpeta_local_aleatorio:doc1'; - expect(builder.esCarpetaLocalMediaId(reproducir), isFalse); - expect(builder.esCarpetaLocalMediaId(aleatorio), isFalse); - expect( - builder.esCarpetaLocalPaginadaMediaId(reproducir), - isFalse, - ); - expect(builder.esCarpetaLocalOrdMediaId(reproducir), isFalse); - expect(builder.esCarpetaLocalBucketMediaId(reproducir), isFalse); - expect(esPistaMediaId(reproducir), isFalse); - - // Divergencia en el índice 14 (justo después de "carpeta_local_"): - // 'r' (reproducir) / 'a' (aleatorio) vs 'p' (_pag) / 'o' (_ord) / - // 'b' (_bucket) — prueba de colisión de Design ADR-5. - expect(reproducir[14], 'r'); - expect(aleatorio[14], 'a'); - expect('carpeta_local_pag:0:doc1'[14], 'p'); - expect('carpeta_local_ord:calidad:0:doc1'[14], 'o'); - expect('carpeta_local_bucket:0:0:doc1'[14], 'b'); - // 'carpeta_local:' diverge en el índice 13 (':' vs '_'). - expect('carpeta_local:doc1'[13], ':'); - expect(reproducir[13], '_'); - }, - ); - - test( - 'idCarpetaLocalReproducirDesde / idCarpetaLocalAleatorioDesde ' - 'recortan por longitud, preservando un documentId con ":" y "/" ' - 'verbatim (Spec "Raw documentIds with :/ survive round-trip")', - () { - final builder = ConstructorArbolAuto(); - const docId = 'primary:Music/Local Folder'; - - expect( - builder.idCarpetaLocalReproducirDesde( - 'carpeta_local_reproducir:$docId', - ), - docId, - ); - expect( - builder.idCarpetaLocalAleatorioDesde( - 'carpeta_local_aleatorio:$docId', - ), - docId, - ); - }, - ); - - test( - 'tail vacío (empty tail) hace round-trip a la raíz local', - () { - final builder = ConstructorArbolAuto(); - - expect( - builder.idCarpetaLocalReproducirDesde('carpeta_local_reproducir:'), - '', - ); - expect( - builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:'), - '', - ); + expect(llamadas, 0); }, ); }, ); - group('artUriLocal', () { - test( - 'reproduce la misma rotación que artUriPara/indiceArtePara, sembrada ' - 'por documentId en vez de por uuid', - () { - const documentIds = ['doc-a', 'doc-b', 'doc-c', 'doc-d']; - const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova']; + group('esCarpetaLocalMediaId / esPistaMediaId', () { + test('reconocen sus prefijos, rechazan el resto y no colisionan entre sí ' + 'ni con emisora:/grupo:/los ids de carpeta fijos', () { + final builder = ConstructorArbolAuto(); - for (final docId in documentIds) { - final indice = indiceArtePara(docId); - expect( - artUriLocal(docId), - 'android.resource://es.freetimelab.pluriwave/drawable/' - 'station_art_${nombresPorIndice[indice]}', - ); - } - }, - ); + expect(builder.esCarpetaLocalMediaId('carpeta_local:doc1'), isTrue); + expect(builder.esCarpetaLocalMediaId('carpeta_local:'), isTrue); + expect(esPistaMediaId('pista:doc1'), isTrue); + expect(esPistaMediaId('pista:'), isTrue); + + final noLocales = [ + 'emisora:x', + 'grupo:g1', + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + '', + ]; + for (final id in noLocales) { + expect( + builder.esCarpetaLocalMediaId(id), + isFalse, + reason: 'esCarpetaLocalMediaId($id) debería ser false', + ); + expect( + esPistaMediaId(id), + isFalse, + reason: 'esPistaMediaId($id) debería ser false', + ); + } + + // carpeta_local: y pista: nunca colisionan entre sí. + expect(builder.esCarpetaLocalMediaId('pista:doc1'), isFalse); + expect(esPistaMediaId('carpeta_local:doc1'), isFalse); + }); + + test('idCarpetaLocalDesde recorta por longitud, preservando un documentId ' + 'que contiene ":" (SAF documentIds reales, p.ej. ' + '"primary:Music/Local")', () { + final builder = ConstructorArbolAuto(); + const docId = 'primary:Music/Local'; + + expect(builder.idCarpetaLocalDesde('carpeta_local:$docId'), docId); + }); + }); + + group('esCarpetaLocalReproducirMediaId / esCarpetaLocalAleatorioMediaId ' + '(Design ADR-5, Phase 3)', () { + test('reconocen sus prefijos, no colisionan entre sí ni con NINGUNO de ' + 'los 8 prefijos/ids existentes (Spec "New Action Media-IDs Are ' + 'Collision-Free")', () { + final builder = ConstructorArbolAuto(); + + expect( + builder.esCarpetaLocalReproducirMediaId( + 'carpeta_local_reproducir:doc1', + ), + isTrue, + ); + expect( + builder.esCarpetaLocalAleatorioMediaId('carpeta_local_aleatorio:doc1'), + isTrue, + ); + + final existentes = [ + 'emisora:x', + 'grupo:g1', + 'carpeta_local:doc1', + 'carpeta_local_pag:0:doc1', + 'carpeta_local_ord:calidad:0:doc1', + 'carpeta_local_bucket:0:0:doc1', + 'pista:doc1', + ]; + for (final id in existentes) { + expect( + builder.esCarpetaLocalReproducirMediaId(id), + isFalse, + reason: + 'esCarpetaLocalReproducirMediaId($id) debería ser ' + 'false', + ); + expect( + builder.esCarpetaLocalAleatorioMediaId(id), + isFalse, + reason: + 'esCarpetaLocalAleatorioMediaId($id) debería ser ' + 'false', + ); + } + + // Y en la dirección inversa: ninguno de los 8 predicados + // existentes reconoce las 2 nuevas acciones. + const reproducir = 'carpeta_local_reproducir:doc1'; + const aleatorio = 'carpeta_local_aleatorio:doc1'; + expect(builder.esCarpetaLocalMediaId(reproducir), isFalse); + expect(builder.esCarpetaLocalMediaId(aleatorio), isFalse); + expect(builder.esCarpetaLocalPaginadaMediaId(reproducir), isFalse); + expect(builder.esCarpetaLocalOrdMediaId(reproducir), isFalse); + expect(builder.esCarpetaLocalBucketMediaId(reproducir), isFalse); + expect(esPistaMediaId(reproducir), isFalse); + + // Divergencia en el índice 14 (justo después de "carpeta_local_"): + // 'r' (reproducir) / 'a' (aleatorio) vs 'p' (_pag) / 'o' (_ord) / + // 'b' (_bucket) — prueba de colisión de Design ADR-5. + expect(reproducir[14], 'r'); + expect(aleatorio[14], 'a'); + expect('carpeta_local_pag:0:doc1'[14], 'p'); + expect('carpeta_local_ord:calidad:0:doc1'[14], 'o'); + expect('carpeta_local_bucket:0:0:doc1'[14], 'b'); + // 'carpeta_local:' diverge en el índice 13 (':' vs '_'). + expect('carpeta_local:doc1'[13], ':'); + expect(reproducir[13], '_'); + }); + + test('idCarpetaLocalReproducirDesde / idCarpetaLocalAleatorioDesde ' + 'recortan por longitud, preservando un documentId con ":" y "/" ' + 'verbatim (Spec "Raw documentIds with :/ survive round-trip")', () { + final builder = ConstructorArbolAuto(); + const docId = 'primary:Music/Local Folder'; + + expect( + builder.idCarpetaLocalReproducirDesde( + 'carpeta_local_reproducir:$docId', + ), + docId, + ); + expect( + builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:$docId'), + docId, + ); + }); + + test('tail vacío (empty tail) hace round-trip a la raíz local', () { + final builder = ConstructorArbolAuto(); + + expect( + builder.idCarpetaLocalReproducirDesde('carpeta_local_reproducir:'), + '', + ); + expect( + builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:'), + '', + ); + }); + }); + + group('artUriLocal', () { + test('reproduce la misma rotación que artUriPara/indiceArtePara, sembrada ' + 'por documentId en vez de por uuid', () { + const documentIds = ['doc-a', 'doc-b', 'doc-c', 'doc-d']; + const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova']; + + for (final docId in documentIds) { + final indice = indiceArtePara(docId); + expect( + artUriLocal(docId), + 'android.resource://es.freetimelab.pluriwave/drawable/' + 'station_art_${nombresPorIndice[indice]}', + ); + } + }); }); group('paginaDe', () { @@ -507,410 +665,367 @@ void main() { ); }); - group( - 'esCarpetaLocalPaginadaMediaId / paginaCarpetaLocalDesde', - () { - test( - 'reconoce el prefijo carpeta_local_pag: y no colisiona con ' + group('esCarpetaLocalPaginadaMediaId / paginaCarpetaLocalDesde', () { + test('reconoce el prefijo carpeta_local_pag: y no colisiona con ' 'emisora:/grupo:/carpeta_local:/pista: ni con los ids de ' - 'carpeta fijos', - () { - final builder = ConstructorArbolAuto(); + 'carpeta fijos', () { + final builder = ConstructorArbolAuto(); - expect( - builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:doc1'), - isTrue, - ); - expect( - builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:'), - isTrue, - ); - - final otros = [ - 'emisora:x', - 'grupo:g1', - 'carpeta_local:doc1', - 'pista:doc1', - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idMusicaLocal, - '', - ]; - for (final id in otros) { - expect( - builder.esCarpetaLocalPaginadaMediaId(id), - isFalse, - reason: 'esCarpetaLocalPaginadaMediaId($id) debería ser false', - ); - } - - // Simétricamente, un carpeta_local: plano nunca matchea el prefijo - // paginado, y viceversa. - expect( - builder.esCarpetaLocalMediaId('carpeta_local_pag:1:doc1'), - isFalse, - ); - }, + expect( + builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:doc1'), + isTrue, + ); + expect( + builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:'), + isTrue, ); - test( - 'parsea (documentId, pagina) recortando el prefijo y separando en ' + final otros = [ + 'emisora:x', + 'grupo:g1', + 'carpeta_local:doc1', + 'pista:doc1', + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + '', + ]; + for (final id in otros) { + expect( + builder.esCarpetaLocalPaginadaMediaId(id), + isFalse, + reason: 'esCarpetaLocalPaginadaMediaId($id) debería ser false', + ); + } + + // Simétricamente, un carpeta_local: plano nunca matchea el prefijo + // paginado, y viceversa. + expect( + builder.esCarpetaLocalMediaId('carpeta_local_pag:1:doc1'), + isFalse, + ); + }); + + test('parsea (documentId, pagina) recortando el prefijo y separando en ' 'el PRIMER ":" — el documentId sobrevive intacto aunque contenga ' - '":" o "/"', - () { - final builder = ConstructorArbolAuto(); + '":" o "/"', () { + final builder = ConstructorArbolAuto(); - expect( - builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:doc1'), - ('doc1', 1), - ); - expect( - builder.paginaCarpetaLocalDesde( - 'carpeta_local_pag:3:primary:Music/Local', - ), - ('primary:Music/Local', 3), - ); - }, + expect(builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:doc1'), ( + 'doc1', + 1, + )); + expect( + builder.paginaCarpetaLocalDesde( + 'carpeta_local_pag:3:primary:Music/Local', + ), + ('primary:Music/Local', 3), ); + }); - test( - 'página raíz con tail vacío ("carpeta_local_pag:1:") parsea a ' - 'documentId vacío', - () { - final builder = ConstructorArbolAuto(); + test('página raíz con tail vacío ("carpeta_local_pag:1:") parsea a ' + 'documentId vacío', () { + final builder = ConstructorArbolAuto(); - expect( - builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:'), - ('', 1), - ); - }, - ); - }, - ); + expect(builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:'), ('', 1)); + }); + }); group('ConstructorArbolAuto.itemsLocales', () { - test( - 'invariante de conteo de llamadas (ADR-3): con 200 nodos, cada página ' - 'invoca construirItem exactamente min(50, restantes) veces — nunca ' - 'una vez por el total de la carpeta', - () async { - final nodos = List.generate( - 200, - (i) => NodoLocal( - documentId: 'doc-$i', - nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', - esDirectorio: false, - ), - ); - final builder = ConstructorArbolAuto(); + test('invariante de conteo de llamadas (ADR-3): con 200 nodos, cada página ' + 'invoca construirItem exactamente min(50, restantes) veces — nunca ' + 'una vez por el total de la carpeta', () async { + final nodos = List.generate( + 200, + (i) => NodoLocal( + documentId: 'doc-$i', + nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', + esDirectorio: false, + ), + ); + final builder = ConstructorArbolAuto(); - var llamadas = 0; - MediaItem espia(NodoLocal nodo, Map _) { - llamadas++; - return MediaItem(id: 'pista:${nodo.documentId}', title: nodo.nombre); - } + var llamadas = 0; + MediaItem espia(NodoLocal nodo, Map _) { + llamadas++; + return MediaItem(id: 'pista:${nodo.documentId}', title: nodo.nombre); + } - llamadas = 0; - await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - pagina: 0, - metadatosDe: _metadatosVacio, - construirItem: espia, - ); - expect(llamadas, 50); + llamadas = 0; + await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: 0, + metadatosDe: _metadatosVacio, + construirItem: espia, + ); + expect(llamadas, 50); - llamadas = 0; - await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - pagina: 1, - metadatosDe: _metadatosVacio, - construirItem: espia, - ); - expect(llamadas, 50); + llamadas = 0; + await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: 1, + metadatosDe: _metadatosVacio, + construirItem: espia, + ); + expect(llamadas, 50); - llamadas = 0; - await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - pagina: 3, - metadatosDe: _metadatosVacio, - construirItem: espia, - ); - expect(llamadas, 50); // 200 - 150 = 50 restantes + llamadas = 0; + await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: 3, + metadatosDe: _metadatosVacio, + construirItem: espia, + ); + expect(llamadas, 50); // 200 - 150 = 50 restantes - llamadas = 0; - await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - pagina: 4, - metadatosDe: _metadatosVacio, - construirItem: espia, - ); - expect(llamadas, 0); // más allá del rango, no queda nada - }, - ); + llamadas = 0; + await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: 4, + metadatosDe: _metadatosVacio, + construirItem: espia, + ); + expect(llamadas, 0); // más allá del rango, no queda nada + }); - test( - 'invariante de resolución de metadata (THE load-bearing test, mirror ' - 'del spy de conteo de arriba): con 200 nodos, cada página invoca ' - 'metadatosDe con EXACTAMENTE los documentIds de esa página — página 0 ' - 'recibe los 50 primeros, página 3 recibe los 50 finales, nunca los ' - '200 completos', - () async { - final nodos = List.generate( - 200, - (i) => NodoLocal( - documentId: 'doc-$i', - nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', - esDirectorio: false, - ), - ); - final builder = ConstructorArbolAuto(); - List? idsRecibidos; + test('invariante de resolución de metadata (THE load-bearing test, mirror ' + 'del spy de conteo de arriba): con 200 nodos, cada página invoca ' + 'metadatosDe con EXACTAMENTE los documentIds de esa página — página 0 ' + 'recibe los 50 primeros, página 3 recibe los 50 finales, nunca los ' + '200 completos', () async { + final nodos = List.generate( + 200, + (i) => NodoLocal( + documentId: 'doc-$i', + nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', + esDirectorio: false, + ), + ); + final builder = ConstructorArbolAuto(); + List? idsRecibidos; - Future> espia(List ids) async { + Future> espia(List ids) async { + idsRecibidos = ids; + return const {}; + } + + idsRecibidos = null; + await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: 0, + metadatosDe: espia, + ); + expect(idsRecibidos, hasLength(50)); + expect(idsRecibidos, List.generate(50, (i) => 'doc-$i')); + + idsRecibidos = null; + await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: 3, + metadatosDe: espia, + ); + expect(idsRecibidos, hasLength(50)); + expect(idsRecibidos, List.generate(50, (i) => 'doc-${150 + i}')); + }); + + test('las carpetas (esDirectorio: true) de la página NO se incluyen en ' + 'los documentIds pasados a metadatosDe', () async { + final nodos = [ + const NodoLocal( + documentId: 'doc-carpeta', + nombre: 'AAA Carpeta', + esDirectorio: true, + ), + const NodoLocal( + documentId: 'doc-pista', + nombre: 'ZZZ Pista.mp3', + esDirectorio: false, + ), + ]; + List? idsRecibidos; + + await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'x', + metadatosDe: (ids) async { idsRecibidos = ids; return const {}; - } + }, + ); - idsRecibidos = null; - await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - pagina: 0, - metadatosDe: espia, - ); - expect(idsRecibidos, hasLength(50)); - expect( - idsRecibidos, - List.generate(50, (i) => 'doc-$i'), - ); + expect(idsRecibidos, ['doc-pista']); + }); - idsRecibidos = null; - await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - pagina: 3, - metadatosDe: espia, - ); - expect(idsRecibidos, hasLength(50)); - expect( - idsRecibidos, - List.generate(50, (i) => 'doc-${150 + i}'), - ); - }, - ); + test('caso con metadata presente: título/artUri resueltos reflejan la ' + 'metadata, no el nombre de archivo ni el placeholder', () async { + const nodo = NodoLocal( + documentId: 'doc-meta', + nombre: 'nombre_crudo.mp3', + esDirectorio: false, + ); + const metadatos = MetadatosPista( + titulo: 'Titulo Embebido', + artista: 'Artista Embebido', + artUri: + 'content://es.freetimelab.pluriwave.fileprovider/cache/' + 'pluriwave_art/abc', + bitrate: 320000, + ); - test( - 'las carpetas (esDirectorio: true) de la página NO se incluyen en ' - 'los documentIds pasados a metadatosDe', - () async { - final nodos = [ - const NodoLocal( - documentId: 'doc-carpeta', - nombre: 'AAA Carpeta', - esDirectorio: true, - ), - const NodoLocal( - documentId: 'doc-pista', - nombre: 'ZZZ Pista.mp3', - esDirectorio: false, - ), - ]; - List? idsRecibidos; + final item = (await ConstructorArbolAuto().itemsLocales( + [nodo], + documentIdPadre: 'x', + metadatosDe: (ids) async => {'doc-meta': metadatos}, + )).singleWhere((i) => i.id.startsWith('pista:')); - await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'x', - metadatosDe: (ids) async { - idsRecibidos = ids; - return const {}; - }, - ); + expect(item.title, 'Titulo Embebido'); + expect(item.artUri.toString(), metadatos.artUri); + expect(item.artist, 'Artista Embebido'); + }); - expect(idsRecibidos, ['doc-pista']); - }, - ); - - test( - 'caso con metadata presente: título/artUri resueltos reflejan la ' - 'metadata, no el nombre de archivo ni el placeholder', - () async { - const nodo = NodoLocal( - documentId: 'doc-meta', - nombre: 'nombre_crudo.mp3', - esDirectorio: false, - ); - const metadatos = MetadatosPista( - titulo: 'Titulo Embebido', - artista: 'Artista Embebido', - artUri: 'content://es.freetimelab.pluriwave.fileprovider/cache/' - 'pluriwave_art/abc', - bitrate: 320000, - ); + test('caso sin metadata (docId ausente del mapa resuelto, o mapa ' + 'completamente vacío): cae al nombre de archivo y al placeholder ' + 'rotativo, sin excepción', () async { + const nodo = NodoLocal( + documentId: 'doc-sin-meta', + nombre: 'Cancion Sin Metadata.mp3', + esDirectorio: false, + ); + for (final resolver in [ + (List ids) async => const {}, + (List ids) async => { + 'doc-otro': const MetadatosPista(titulo: 'No debería usarse'), + }, + ]) { final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', - metadatosDe: (ids) async => {'doc-meta': metadatos}, + metadatosDe: resolver, )).singleWhere((i) => i.id.startsWith('pista:')); - expect(item.title, 'Titulo Embebido'); - expect(item.artUri.toString(), metadatos.artUri); - expect(item.artist, 'Artista Embebido'); - }, - ); + expect(item.title, 'Cancion Sin Metadata'); + expect(item.artUri.toString(), artUriLocal('doc-sin-meta')); + } + }); - test( - 'caso sin metadata (docId ausente del mapa resuelto, o mapa ' - 'completamente vacío): cae al nombre de archivo y al placeholder ' - 'rotativo, sin excepción', - () async { - const nodo = NodoLocal( - documentId: 'doc-sin-meta', - nombre: 'Cancion Sin Metadata.mp3', + test('exactamente 50 items: no aparece "Más…", nada se pierde', () async { + final nodos = List.generate( + 50, + (i) => NodoLocal( + documentId: 'doc-$i', + nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, + ), + )..shuffle(); + + final items = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + ); + + // 50 pistas: <=150 -> entrada de calidad presente; exactamente 50 + // NO dispara buckets (umbral es "> 50", Design ADR-4). totalPistas + // > 0 también antepone las 2 acciones "Reproducir carpeta"/ + // "Reproducir aleatorio" (Design ADR-5, Phase 3) -> 2 + 1 + 50 = 53. + final pistas = items.where((i) => i.id.startsWith('pista:')).toList(); + expect(items, hasLength(53)); + expect(pistas, hasLength(50)); + expect(pistas.first.title, 'cancion_00'); + expect(pistas.last.title, 'cancion_49'); + expect(items.any((i) => i.title == 'Más…'), isFalse); + }); + + test('51 a 100 items: primera página trae 50 + "Más…"; la última página ' + 'trae el resto sin "Más…"', () async { + final nodos = List.generate( + 60, + (i) => NodoLocal( + documentId: 'doc-$i', + nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', + esDirectorio: false, + ), + )..shuffle(); + final builder = ConstructorArbolAuto(); + + final pagina0 = await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + ); + // 60 pistas: <=150 -> entrada de calidad; >50 -> 4 buckets; y las 2 + // acciones "Reproducir carpeta"/"Reproducir aleatorio" (Design + // ADR-5, Phase 3) -- todo prepended antes de las 50 pistas + "Más…" + // (Design ADR-4). 2 + 1 + 4 + 50 + 1 = 58. + final pistas0 = pagina0.where((i) => i.id.startsWith('pista:')).toList(); + expect(pagina0, hasLength(58)); + expect(pistas0, hasLength(50)); + expect(pistas0.first.title, 'cancion_00'); + expect(pistas0[49].title, 'cancion_49'); + final mas0 = pagina0.last; + expect(mas0.title, 'Más…'); + expect(mas0.playable, isFalse); + expect(mas0.artUri, isNull); + + final pagina1 = await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: 1, + metadatosDe: _metadatosVacio, + ); + expect(pagina1, hasLength(10)); + expect(pagina1.last.title, 'cancion_59'); + expect(pagina1.any((i) => i.title == 'Más…'), isFalse); + }); + + test('100+ items encadena 3+ páginas, cada item es alcanzable y nada se ' + 'pierde', () async { + final nodos = List.generate( + 120, + (i) => NodoLocal( + documentId: 'doc-$i', + nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', + esDirectorio: false, + ), + )..shuffle(); + final builder = ConstructorArbolAuto(); + + final vistos = {}; + var pagina = 0; + var seguir = true; + var paginasVisitadas = 0; + while (seguir) { + final items = await builder.itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: pagina, + metadatosDe: _metadatosVacio, ); - - for (final resolver in [ - (List ids) async => const {}, - (List ids) async => { - 'doc-otro': const MetadatosPista(titulo: 'No debería usarse'), - }, - ]) { - final item = (await ConstructorArbolAuto().itemsLocales( - [nodo], - documentIdPadre: 'x', - metadatosDe: resolver, - )).singleWhere((i) => i.id.startsWith('pista:')); - - expect(item.title, 'Cancion Sin Metadata'); - expect(item.artUri.toString(), artUriLocal('doc-sin-meta')); + paginasVisitadas++; + final tieneMas = items.isNotEmpty && items.last.title == 'Más…'; + final soloItemsMas = + tieneMas ? items.sublist(0, items.length - 1) : items; + // Excluye las entradas de modo/bucket prepended en página 0 + // (Design ADR-4) — este test cuenta pistas, no vistas. + final hojas = soloItemsMas.where((i) => i.id.startsWith('pista:')); + for (final item in hojas) { + vistos.add(item.id); } - }, - ); + seguir = tieneMas; + pagina++; + } - test( - 'exactamente 50 items: no aparece "Más…", nada se pierde', - () async { - final nodos = List.generate( - 50, - (i) => NodoLocal( - documentId: 'doc-$i', - nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', - esDirectorio: false, - ), - )..shuffle(); - - final items = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - ); - - // 50 pistas: <=150 -> entrada de calidad presente; exactamente 50 - // NO dispara buckets (umbral es "> 50", Design ADR-4). totalPistas - // > 0 también antepone las 2 acciones "Reproducir carpeta"/ - // "Reproducir aleatorio" (Design ADR-5, Phase 3) -> 2 + 1 + 50 = 53. - final pistas = items.where((i) => i.id.startsWith('pista:')).toList(); - expect(items, hasLength(53)); - expect(pistas, hasLength(50)); - expect(pistas.first.title, 'cancion_00'); - expect(pistas.last.title, 'cancion_49'); - expect(items.any((i) => i.title == 'Más…'), isFalse); - }, - ); - - test( - '51 a 100 items: primera página trae 50 + "Más…"; la última página ' - 'trae el resto sin "Más…"', - () async { - final nodos = List.generate( - 60, - (i) => NodoLocal( - documentId: 'doc-$i', - nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', - esDirectorio: false, - ), - )..shuffle(); - final builder = ConstructorArbolAuto(); - - final pagina0 = await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - ); - // 60 pistas: <=150 -> entrada de calidad; >50 -> 4 buckets; y las 2 - // acciones "Reproducir carpeta"/"Reproducir aleatorio" (Design - // ADR-5, Phase 3) -- todo prepended antes de las 50 pistas + "Más…" - // (Design ADR-4). 2 + 1 + 4 + 50 + 1 = 58. - final pistas0 = pagina0.where((i) => i.id.startsWith('pista:')).toList(); - expect(pagina0, hasLength(58)); - expect(pistas0, hasLength(50)); - expect(pistas0.first.title, 'cancion_00'); - expect(pistas0[49].title, 'cancion_49'); - final mas0 = pagina0.last; - expect(mas0.title, 'Más…'); - expect(mas0.playable, isFalse); - expect(mas0.artUri, isNull); - - final pagina1 = await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - pagina: 1, - metadatosDe: _metadatosVacio, - ); - expect(pagina1, hasLength(10)); - expect(pagina1.last.title, 'cancion_59'); - expect(pagina1.any((i) => i.title == 'Más…'), isFalse); - }, - ); - - test( - '100+ items encadena 3+ páginas, cada item es alcanzable y nada se ' - 'pierde', - () async { - final nodos = List.generate( - 120, - (i) => NodoLocal( - documentId: 'doc-$i', - nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', - esDirectorio: false, - ), - )..shuffle(); - final builder = ConstructorArbolAuto(); - - final vistos = {}; - var pagina = 0; - var seguir = true; - var paginasVisitadas = 0; - while (seguir) { - final items = await builder.itemsLocales( - nodos, - documentIdPadre: 'x', - pagina: pagina, - metadatosDe: _metadatosVacio, - ); - paginasVisitadas++; - final tieneMas = items.isNotEmpty && items.last.title == 'Más…'; - final soloItemsMas = tieneMas ? items.sublist(0, items.length - 1) : items; - // Excluye las entradas de modo/bucket prepended en página 0 - // (Design ADR-4) — este test cuenta pistas, no vistas. - final hojas = soloItemsMas.where((i) => i.id.startsWith('pista:')); - for (final item in hojas) { - vistos.add(item.id); - } - seguir = tieneMas; - pagina++; - } - - expect(paginasVisitadas, greaterThanOrEqualTo(3)); - expect(vistos, hasLength(120)); - }, - ); + expect(paginasVisitadas, greaterThanOrEqualTo(3)); + expect(vistos, hasLength(120)); + }); test( 'el item "Más…" tiene forma round-trip: id ' @@ -938,63 +1053,57 @@ void main() { expect(mas.title, 'Más…'); expect(mas.playable, isFalse); expect(mas.artUri, isNull); - expect( - builder.esCarpetaLocalPaginadaMediaId(mas.id), - isTrue, - ); - expect( - builder.paginaCarpetaLocalDesde(mas.id), - ('sub1', 1), - ); + expect(builder.esCarpetaLocalPaginadaMediaId(mas.id), isTrue); + expect(builder.paginaCarpetaLocalDesde(mas.id), ('sub1', 1)); }, ); - test('paginación en la raíz (documentIdPadre vacío) también funciona', () async { - final nodos = List.generate( - 51, - (i) => NodoLocal( - documentId: 'doc-$i', - nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', - esDirectorio: false, - ), - ); - final builder = ConstructorArbolAuto(); - - final items = await builder.itemsLocales( - nodos, - documentIdPadre: '', - metadatosDe: _metadatosVacio, - ); - final mas = items.last; - - expect(mas.id, 'carpeta_local_pag:1:'); - expect(builder.paginaCarpetaLocalDesde(mas.id), ('', 1)); - }); - test( - 'página obsoleta más allá del rango (la carpeta se achicó) devuelve ' - 'lista vacía, sin "Más…" y sin lanzar', + 'paginación en la raíz (documentIdPadre vacío) también funciona', () async { final nodos = List.generate( - 10, + 51, (i) => NodoLocal( documentId: 'doc-$i', - nombre: 'cancion_$i.mp3', + nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, ), ); + final builder = ConstructorArbolAuto(); - final items = await ConstructorArbolAuto().itemsLocales( + final items = await builder.itemsLocales( nodos, - documentIdPadre: 'x', - pagina: 5, + documentIdPadre: '', metadatosDe: _metadatosVacio, ); + final mas = items.last; - expect(items, isEmpty); + expect(mas.id, 'carpeta_local_pag:1:'); + expect(builder.paginaCarpetaLocalDesde(mas.id), ('', 1)); }, ); + test('página obsoleta más allá del rango (la carpeta se achicó) devuelve ' + 'lista vacía, sin "Más…" y sin lanzar', () async { + final nodos = List.generate( + 10, + (i) => NodoLocal( + documentId: 'doc-$i', + nombre: 'cancion_$i.mp3', + esDirectorio: false, + ), + ); + + final items = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'x', + pagina: 5, + metadatosDe: _metadatosVacio, + ); + + expect(items, isEmpty); + }); + test('subcarpeta vacía devuelve lista vacía, no un error', () async { expect( await ConstructorArbolAuto().itemsLocales( @@ -1006,54 +1115,31 @@ void main() { ); }); - test( - 'una carpeta mapea a un item no reproducible carpeta_local: con ' - 'el nombre crudo (sin recortar extensión)', - () async { - const nodo = NodoLocal( - documentId: 'doc-carpeta', - nombre: 'Mi Carpeta', - esDirectorio: true, - ); - - final item = (await ConstructorArbolAuto().itemsLocales( - [nodo], - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - )).single; - - expect(item.id, 'carpeta_local:doc-carpeta'); - expect(item.playable, isFalse); - expect(item.title, 'Mi Carpeta'); - }, - ); - - test( - 'un archivo mapea a un item reproducible pista: con título = ' - 'nombre sin la última extensión', - () async { - const nodo = NodoLocal( - documentId: 'doc-pista', - nombre: 'Cancion Genial.mp3', - esDirectorio: false, - ); - - final item = (await ConstructorArbolAuto().itemsLocales( - [nodo], - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - )).singleWhere((i) => i.id.startsWith('pista:')); - - expect(item.id, 'pista:doc-pista'); - expect(item.playable, isTrue); - expect(item.title, 'Cancion Genial'); - }, - ); - - test('un archivo sin punto en el nombre conserva el nombre completo', () async { + test('una carpeta mapea a un item no reproducible carpeta_local: con ' + 'el nombre crudo (sin recortar extensión)', () async { const nodo = NodoLocal( - documentId: 'doc-sin-ext', - nombre: 'CancionSinExtension', + documentId: 'doc-carpeta', + nombre: 'Mi Carpeta', + esDirectorio: true, + ); + + final item = + (await ConstructorArbolAuto().itemsLocales( + [nodo], + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + )).single; + + expect(item.id, 'carpeta_local:doc-carpeta'); + expect(item.playable, isFalse); + expect(item.title, 'Mi Carpeta'); + }); + + test('un archivo mapea a un item reproducible pista: con título = ' + 'nombre sin la última extensión', () async { + const nodo = NodoLocal( + documentId: 'doc-pista', + nombre: 'Cancion Genial.mp3', esDirectorio: false, ); @@ -1063,55 +1149,17 @@ void main() { metadatosDe: _metadatosVacio, )).singleWhere((i) => i.id.startsWith('pista:')); - expect(item.title, 'CancionSinExtension'); + expect(item.id, 'pista:doc-pista'); + expect(item.playable, isTrue); + expect(item.title, 'Cancion Genial'); }); test( - 'un archivo con nombre en blanco cae al título de reserva, nunca ' - 'queda vacío', - () async { - for (final nombre in ['', ' ']) { - final nodo = NodoLocal( - documentId: 'doc-blanco', - nombre: nombre, - esDirectorio: false, - ); - final item = (await ConstructorArbolAuto().itemsLocales( - [nodo], - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - )).singleWhere((i) => i.id.startsWith('pista:')); - expect(item.title, isNotEmpty); - } - }, - ); - - test( - 'un archivo cuyo nombre es solo la extensión (punto en posición 0, ' - 'p.ej. ".mp3") conserva el nombre completo en vez de recortar a ' - 'blanco', + 'un archivo sin punto en el nombre conserva el nombre completo', () async { const nodo = NodoLocal( - documentId: 'doc-oculto', - nombre: '.mp3', - esDirectorio: false, - ); - final item = (await ConstructorArbolAuto().itemsLocales( - [nodo], - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - )).singleWhere((i) => i.id.startsWith('pista:')); - expect(item.title, '.mp3'); - }, - ); - - test( - 'el artUri de un archivo sin metadata usa la misma rotación que ' - 'artUriLocal, sembrada con el documentId', - () async { - const nodo = NodoLocal( - documentId: 'doc-arte', - nombre: 'Cancion.mp3', + documentId: 'doc-sin-ext', + nombre: 'CancionSinExtension', esDirectorio: false, ); @@ -1121,83 +1169,180 @@ void main() { metadatosDe: _metadatosVacio, )).singleWhere((i) => i.id.startsWith('pista:')); - expect(item.artUri.toString(), artUriLocal('doc-arte')); - expect( - item.artUri.toString(), - startsWith( - 'android.resource://es.freetimelab.pluriwave/drawable/' - 'station_art_', - ), - ); + expect(item.title, 'CancionSinExtension'); }, ); + + test('un archivo con nombre en blanco cae al título de reserva, nunca ' + 'queda vacío', () async { + for (final nombre in ['', ' ']) { + final nodo = NodoLocal( + documentId: 'doc-blanco', + nombre: nombre, + esDirectorio: false, + ); + final item = (await ConstructorArbolAuto().itemsLocales( + [nodo], + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + )).singleWhere((i) => i.id.startsWith('pista:')); + expect(item.title, isNotEmpty); + } + }); + + test('un archivo cuyo nombre es solo la extensión (punto en posición 0, ' + 'p.ej. ".mp3") conserva el nombre completo en vez de recortar a ' + 'blanco', () async { + const nodo = NodoLocal( + documentId: 'doc-oculto', + nombre: '.mp3', + esDirectorio: false, + ); + final item = (await ConstructorArbolAuto().itemsLocales( + [nodo], + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + )).singleWhere((i) => i.id.startsWith('pista:')); + expect(item.title, '.mp3'); + }); + + test('el artUri de un archivo sin metadata usa la misma rotación que ' + 'artUriLocal, sembrada con el documentId', () async { + const nodo = NodoLocal( + documentId: 'doc-arte', + nombre: 'Cancion.mp3', + esDirectorio: false, + ); + + final item = (await ConstructorArbolAuto().itemsLocales( + [nodo], + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + )).singleWhere((i) => i.id.startsWith('pista:')); + + expect(item.artUri.toString(), artUriLocal('doc-arte')); + expect( + item.artUri.toString(), + startsWith( + 'android.resource://es.freetimelab.pluriwave/drawable/' + 'station_art_', + ), + ); + }); }); group('compararCalidadLocal / ordenarPorCalidadLocal', () { + test('ordena por bitrate descendente; bitrate null siempre queda al ' + 'final, sin lanzar', () { + final nodos = [ + const NodoLocal( + documentId: 'd-128', + nombre: 'b.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-null', + nombre: 'a.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-320', + nombre: 'c.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-64', + nombre: 'd.mp3', + esDirectorio: false, + ), + ]; + const metadatos = { + 'd-128': MetadatosPista(bitrate: 128000), + 'd-320': MetadatosPista(bitrate: 320000), + 'd-64': MetadatosPista(bitrate: 64000), + // 'd-null' deliberately absent from the resolved map. + }; + + final ordenados = ordenarPorCalidadLocal(nodos, metadatos); + + expect(ordenados.map((n) => n.documentId).toList(), [ + 'd-320', + 'd-128', + 'd-64', + 'd-null', + ]); + }); + test( - 'ordena por bitrate descendente; bitrate null siempre queda al ' - 'final, sin lanzar', + 'múltiples bitrates desconocidos no lanzan y mantienen orden estable', () { final nodos = [ - const NodoLocal(documentId: 'd-128', nombre: 'b.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-null', nombre: 'a.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-320', nombre: 'c.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-64', nombre: 'd.mp3', esDirectorio: false), + const NodoLocal( + documentId: 'd-1', + nombre: 'a.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-2', + nombre: 'b.mp3', + esDirectorio: false, + ), ]; - const metadatos = { - 'd-128': MetadatosPista(bitrate: 128000), - 'd-320': MetadatosPista(bitrate: 320000), - 'd-64': MetadatosPista(bitrate: 64000), - // 'd-null' deliberately absent from the resolved map. - }; - final ordenados = ordenarPorCalidadLocal(nodos, metadatos); + final ordenados = ordenarPorCalidadLocal(nodos, const {}); - expect( - ordenados.map((n) => n.documentId).toList(), - ['d-320', 'd-128', 'd-64', 'd-null'], - ); + expect(ordenados, hasLength(2)); }, ); - - test('múltiples bitrates desconocidos no lanzan y mantienen orden estable', () { - final nodos = [ - const NodoLocal(documentId: 'd-1', nombre: 'a.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-2', nombre: 'b.mp3', esDirectorio: false), - ]; - - final ordenados = ordenarPorCalidadLocal(nodos, const {}); - - expect(ordenados, hasLength(2)); - }); }); group('pistasEnOrdenNombre', () { test('excluye directorios y ordena por nombre (mismo comparador que ' 'itemsLocales usa para la vista página 0)', () { final nodos = [ - const NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false), + const NodoLocal( + documentId: 'd-c', + nombre: 'c.mp3', + esDirectorio: false, + ), const NodoLocal( documentId: 'd-carpeta', nombre: 'AAA Carpeta', esDirectorio: true, ), - const NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false), + const NodoLocal( + documentId: 'd-a', + nombre: 'a.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-b', + nombre: 'b.mp3', + esDirectorio: false, + ), ]; final ordenados = pistasEnOrdenNombre(nodos); - expect( - ordenados.map((n) => n.documentId).toList(), - ['d-a', 'd-b', 'd-c'], - ); + expect(ordenados.map((n) => n.documentId).toList(), [ + 'd-a', + 'd-b', + 'd-c', + ]); }); test('no muta la lista original', () { final original = [ - const NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false), + const NodoLocal( + documentId: 'd-b', + nombre: 'b.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-a', + nombre: 'a.mp3', + esDirectorio: false, + ), ]; final copia = List.from(original); @@ -1263,8 +1408,13 @@ void main() { } for (final vistos in posicionesVistas) { - expect(vistos, hasLength(5), reason: 'cada posición debería haber ' - 'visto los 5 documentIds posibles a lo largo de 1000 corridas'); + expect( + vistos, + hasLength(5), + reason: + 'cada posición debería haber ' + 'visto los 5 documentIds posibles a lo largo de 1000 corridas', + ); } }); @@ -1274,10 +1424,9 @@ void main() { test('un solo elemento devuelve una lista de un elemento, sin lanzar', () { final nodos = nodosDePrueba(1); - expect( - mezclarFisherYates(nodos, Random(1)).map((n) => n.documentId), - ['d0'], - ); + expect(mezclarFisherYates(nodos, Random(1)).map((n) => n.documentId), [ + 'd0', + ]); }); test('pistasEnOrdenAleatorio mezcla SOBRE el orden canónico por nombre ' @@ -1311,17 +1460,14 @@ void main() { }); group('ConstructorArbolAuto.ofreceOrdenCalidad', () { - test( - 'boundary de _maxPistasParaOrdenCalidad (150): 149 y 150 ofrecen la ' - 'entrada de calidad, 151 la omite', - () { - final builder = ConstructorArbolAuto(); + test('boundary de _maxPistasParaOrdenCalidad (150): 149 y 150 ofrecen la ' + 'entrada de calidad, 151 la omite', () { + final builder = ConstructorArbolAuto(); - expect(builder.ofreceOrdenCalidad(149), isTrue); - expect(builder.ofreceOrdenCalidad(150), isTrue); - expect(builder.ofreceOrdenCalidad(151), isFalse); - }, - ); + expect(builder.ofreceOrdenCalidad(149), isTrue); + expect(builder.ofreceOrdenCalidad(150), isTrue); + expect(builder.ofreceOrdenCalidad(151), isFalse); + }); test('0 pistas nunca ofrece la entrada de calidad', () { expect(ConstructorArbolAuto().ofreceOrdenCalidad(0), isFalse); @@ -1342,10 +1488,26 @@ void main() { 'particiona nombres en rangos alfabéticos contiguos A-F/G-M/N-S/T-Z', () { final nodos = [ - const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-g', nombre: 'Gato.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-n', nombre: 'Noche.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-t', nombre: 'Tren.mp3', esDirectorio: false), + const NodoLocal( + documentId: 'd-a', + nombre: 'Amor.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-g', + nombre: 'Gato.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-n', + nombre: 'Noche.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-t', + nombre: 'Tren.mp3', + esDirectorio: false, + ), ]; final buckets = bucketsDe(nodos); @@ -1362,18 +1524,19 @@ void main() { }, ); - test( - 'un bucket sin coincidencias devuelve lista vacía, no un error', - () { - final nodos = [ - const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), - ]; + test('un bucket sin coincidencias devuelve lista vacía, no un error', () { + final nodos = [ + const NodoLocal( + documentId: 'd-a', + nombre: 'Amor.mp3', + esDirectorio: false, + ), + ]; - final buckets = bucketsDe(nodos); + final buckets = bucketsDe(nodos); - expect(buckets[3].nodos, isEmpty); // T-Z sin coincidencias - }, - ); + expect(buckets[3].nodos, isEmpty); // T-Z sin coincidencias + }); test( 'partición usa SOLO NodoLocal.nombre — bucketsDe no puede invocar ' @@ -1383,7 +1546,11 @@ void main() { // metadatosDe — de haberla, este call site fallaría a compilar. Esa // ausencia ES la prueba de que la partición es metadata-free. final nodos = [ - const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), + const NodoLocal( + documentId: 'd-a', + nombre: 'Amor.mp3', + esDirectorio: false, + ), ]; expect(() => bucketsDe(nodos), returnsNormally); @@ -1392,7 +1559,11 @@ void main() { test('excluye carpetas (esDirectorio: true) de la partición', () { final nodos = [ - const NodoLocal(documentId: 'd-carpeta', nombre: 'Album.mp3', esDirectorio: true), + const NodoLocal( + documentId: 'd-carpeta', + nombre: 'Album.mp3', + esDirectorio: true, + ), ]; final buckets = bucketsDe(nodos); @@ -1411,17 +1582,23 @@ void main() { final builder = ConstructorArbolAuto(); expect( - builder.esCarpetaLocalOrdMediaId('carpeta_local_ord:calidad:0:doc1'), + builder.esCarpetaLocalOrdMediaId( + 'carpeta_local_ord:calidad:0:doc1', + ), isTrue, ); expect( - builder.esCarpetaLocalBucketMediaId('carpeta_local_bucket:0:0:doc1'), + builder.esCarpetaLocalBucketMediaId( + 'carpeta_local_bucket:0:0:doc1', + ), isTrue, ); // Nunca ambos matchean el mismo id. expect( - builder.esCarpetaLocalBucketMediaId('carpeta_local_ord:calidad:0:doc1'), + builder.esCarpetaLocalBucketMediaId( + 'carpeta_local_ord:calidad:0:doc1', + ), isFalse, ); expect( @@ -1432,7 +1609,7 @@ void main() { final otros = [ 'emisora:x', 'grupo:g1', - 'carpeta_local:doc1', + 'carpeta_local:doc1', 'carpeta_local_pag:1:doc1', 'pista:doc1', ConstructorArbolAuto.idFavoritos, @@ -1456,310 +1633,129 @@ void main() { }, ); - test( - 'ordenLocalDesde parsea (modo, documentId, pagina), preservando un ' - 'documentId con ":" o "/"', - () { - final builder = ConstructorArbolAuto(); - - expect( - builder.ordenLocalDesde('carpeta_local_ord:calidad:0:doc1'), - ('calidad', 'doc1', 0), - ); - expect( - builder.ordenLocalDesde( - 'carpeta_local_ord:calidad:2:primary:Music/Local', - ), - ('calidad', 'primary:Music/Local', 2), - ); - }, - ); - - test( - 'bucketLocalDesde parsea (idxBucket, documentId, pagina), ' - 'preservando un documentId con ":" o "/"', - () { - final builder = ConstructorArbolAuto(); - - expect( - builder.bucketLocalDesde('carpeta_local_bucket:0:0:doc1'), - (0, 'doc1', 0), - ); - expect( - builder.bucketLocalDesde( - 'carpeta_local_bucket:3:1:primary:Music/Local', - ), - (3, 'primary:Music/Local', 1), - ); - }, - ); - }, - ); - - group('ConstructorArbolAuto.itemsLocales: page-0 mode/bucket prepend', () { - test( - 'folder con >150 pistas: SIN entrada de calidad; folder con >50 ' - 'pistas: CON entradas de bucket; ambas prepended antes de la lista ' - 'ordenada por nombre, solo en página 0', - () async { - final nodos60 = List.generate( - 60, - (i) => NodoLocal( - documentId: 'd$i', - nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', - esDirectorio: false, - ), - ); + test('ordenLocalDesde parsea (modo, documentId, pagina), preservando un ' + 'documentId con ":" o "/"', () { final builder = ConstructorArbolAuto(); - final pagina0 = await builder.itemsLocales( - nodos60, - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - ); - - // 60 pistas <= 150: entrada de calidad presente; > 50: 4 buckets. + expect(builder.ordenLocalDesde('carpeta_local_ord:calidad:0:doc1'), ( + 'calidad', + 'doc1', + 0, + )); expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), - hasLength(1), - ); - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), - hasLength(4), - ); - // Prepended ANTES de la lista ordenada: los primeros 2 items son - // las acciones de reproducción (Design ADR-5, Phase 3, PLAYABLE), - // seguidas por los 5 items modo+buckets (no playable, no pistas). - expect(pagina0[0].id, startsWith('carpeta_local_reproducir:')); - expect(pagina0[0].playable, isTrue); - expect(pagina0[1].id, startsWith('carpeta_local_aleatorio:')); - expect(pagina0[1].playable, isTrue); - for (var i = 2; i < 7; i++) { - expect(pagina0[i].playable, isFalse); - } - - final pagina1 = await builder.itemsLocales( - nodos60, - documentIdPadre: 'x', - pagina: 1, - metadatosDe: _metadatosVacio, - ); - expect( - pagina1.where((i) => i.id.startsWith('carpeta_local_ord:')), - isEmpty, - ); - expect( - pagina1.where((i) => i.id.startsWith('carpeta_local_bucket:')), - isEmpty, - ); - }, - ); - - test( - 'folder con 151 pistas: entrada de calidad OMITIDA, buckets ' - 'presentes (>50)', - () async { - final nodos151 = List.generate( - 151, - (i) => NodoLocal( - documentId: 'd$i', - nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', - esDirectorio: false, + builder.ordenLocalDesde( + 'carpeta_local_ord:calidad:2:primary:Music/Local', ), + ('calidad', 'primary:Music/Local', 2), ); + }); - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos151, - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - ); - - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), - isEmpty, - ); - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), - hasLength(4), - ); - }, - ); - - test( - 'folder con <=50 pistas: ni calidad-arriba-de-cero-hasta-150 cambia, ' - 'ni buckets aparecen', - () async { - final nodos10 = List.generate( - 10, - (i) => NodoLocal( - documentId: 'd$i', - nombre: 'cancion_$i.mp3', - esDirectorio: false, - ), - ); - - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos10, - documentIdPadre: 'x', - metadatosDe: _metadatosVacio, - ); - - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), - hasLength(1), - ); - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), - isEmpty, - ); - }, - ); - }); - - group( - 'ConstructorArbolAuto.itemsLocales: page-0 folder-play actions ' - '(Design ADR-5, Phase 3 task 4.1)', - () { - test( - 'folder con >=1 pista de audio directa antepone "Reproducir ' - 'carpeta" + "Reproducir aleatorio", ambas playable:true, con id ' - 'prefijado (Spec "Folder has tracks")', - () async { - final nodos = [ - const NodoLocal( - documentId: 'd-a', - nombre: 'a.mp3', - esDirectorio: false, - ), - ]; - - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'padre1', - metadatosDe: _metadatosVacio, - ); - - final reproducir = pagina0.where( - (i) => i.id.startsWith('carpeta_local_reproducir:'), - ); - final aleatorio = pagina0.where( - (i) => i.id.startsWith('carpeta_local_aleatorio:'), - ); - expect(reproducir, hasLength(1)); - expect(aleatorio, hasLength(1)); - expect(reproducir.single.id, 'carpeta_local_reproducir:padre1'); - expect(reproducir.single.playable, isTrue); - expect(aleatorio.single.id, 'carpeta_local_aleatorio:padre1'); - expect(aleatorio.single.playable, isTrue); - }, - ); - - test( - 'folder con 0 pistas de audio directas (solo subcarpetas) NO ' - 'antepone ninguna de las 2 acciones (Spec "Folder has no tracks", ' - 'edge case de carpeta vacía)', - () async { - final nodos = [ - const NodoLocal( - documentId: 'd-sub', - nombre: 'Subcarpeta', - esDirectorio: true, - ), - ]; - - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'padre1', - metadatosDe: _metadatosVacio, - ); - - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')), - isEmpty, - ); - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), - isEmpty, - ); - }, - ); - - test('las acciones solo aparecen en página 0, nunca en páginas ' - 'siguientes', () async { - final nodos = List.generate( - 60, - (i) => NodoLocal( - documentId: 'd$i', - nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', - esDirectorio: false, - ), - ); + test('bucketLocalDesde parsea (idxBucket, documentId, pagina), ' + 'preservando un documentId con ":" o "/"', () { final builder = ConstructorArbolAuto(); - final pagina1 = await builder.itemsLocales( - nodos, - documentIdPadre: 'padre1', - pagina: 1, - metadatosDe: _metadatosVacio, - ); - + expect(builder.bucketLocalDesde('carpeta_local_bucket:0:0:doc1'), ( + 0, + 'doc1', + 0, + )); expect( - pagina1.where((i) => i.id.startsWith('carpeta_local_reproducir:')), - isEmpty, - ); - expect( - pagina1.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), - isEmpty, + builder.bucketLocalDesde( + 'carpeta_local_bucket:3:1:primary:Music/Local', + ), + (3, 'primary:Music/Local', 1), ); }); }, ); - group('ConstructorArbolAuto.itemsLocalesOrdenCalidad', () { - test( - 'ordena TODA la carpeta por bitrate desc vía UN batch de metadatosDe ' - '(full-folder, no page-scoped) y luego pagina', - () async { - final nodos = List.generate( - 5, - (i) => NodoLocal( - documentId: 'd$i', - nombre: 'cancion_$i.mp3', - esDirectorio: false, - ), - ); - final metadatos = { - 'd0': const MetadatosPista(bitrate: 64000), - 'd1': const MetadatosPista(bitrate: 320000), - 'd2': const MetadatosPista(bitrate: 128000), - 'd3': const MetadatosPista(bitrate: 256000), - 'd4': const MetadatosPista(bitrate: 192000), - }; - var llamadas = 0; - List? idsRecibidos; - - final items = await ConstructorArbolAuto().itemsLocalesOrdenCalidad( - nodos, - documentIdPadre: 'x', - metadatosDe: (ids) async { - llamadas++; - idsRecibidos = ids; - return metadatos; - }, - ); - - expect(llamadas, 1); - expect(idsRecibidos, hasLength(5)); // full-folder, not page-scoped - expect( - items.map((i) => i.id).toList(), - ['pista:d1', 'pista:d3', 'pista:d4', 'pista:d2', 'pista:d0'], - ); - }, - ); - - test('paginación con "Más…" enrutado a carpeta_local_ord:calidad:', () async { - final nodos = List.generate( + group('ConstructorArbolAuto.itemsLocales: page-0 mode/bucket prepend', () { + test('folder con >150 pistas: SIN entrada de calidad; folder con >50 ' + 'pistas: CON entradas de bucket; ambas prepended antes de la lista ' + 'ordenada por nombre, solo en página 0', () async { + final nodos60 = List.generate( 60, + (i) => NodoLocal( + documentId: 'd$i', + nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', + esDirectorio: false, + ), + ); + final builder = ConstructorArbolAuto(); + + final pagina0 = await builder.itemsLocales( + nodos60, + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + ); + + // 60 pistas <= 150: entrada de calidad presente; > 50: 4 buckets. + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), + hasLength(1), + ); + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), + hasLength(4), + ); + // Prepended ANTES de la lista ordenada: los primeros 2 items son + // las acciones de reproducción (Design ADR-5, Phase 3, PLAYABLE), + // seguidas por los 5 items modo+buckets (no playable, no pistas). + expect(pagina0[0].id, startsWith('carpeta_local_reproducir:')); + expect(pagina0[0].playable, isTrue); + expect(pagina0[1].id, startsWith('carpeta_local_aleatorio:')); + expect(pagina0[1].playable, isTrue); + for (var i = 2; i < 7; i++) { + expect(pagina0[i].playable, isFalse); + } + + final pagina1 = await builder.itemsLocales( + nodos60, + documentIdPadre: 'x', + pagina: 1, + metadatosDe: _metadatosVacio, + ); + expect( + pagina1.where((i) => i.id.startsWith('carpeta_local_ord:')), + isEmpty, + ); + expect( + pagina1.where((i) => i.id.startsWith('carpeta_local_bucket:')), + isEmpty, + ); + }); + + test('folder con 151 pistas: entrada de calidad OMITIDA, buckets ' + 'presentes (>50)', () async { + final nodos151 = List.generate( + 151, + (i) => NodoLocal( + documentId: 'd$i', + nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', + esDirectorio: false, + ), + ); + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos151, + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + ); + + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), + isEmpty, + ); + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), + hasLength(4), + ); + }); + + test('folder con <=50 pistas: ni calidad-arriba-de-cero-hasta-150 cambia, ' + 'ni buckets aparecen', () async { + final nodos10 = List.generate( + 10, (i) => NodoLocal( documentId: 'd$i', nombre: 'cancion_$i.mp3', @@ -1767,126 +1763,293 @@ void main() { ), ); - final pagina0 = await ConstructorArbolAuto().itemsLocalesOrdenCalidad( - nodos, + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos10, documentIdPadre: 'x', metadatosDe: _metadatosVacio, ); - expect(pagina0, hasLength(51)); - final mas = pagina0.last; - expect(mas.title, 'Más…'); expect( - ConstructorArbolAuto().esCarpetaLocalOrdMediaId(mas.id), - isTrue, + pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), + hasLength(1), ); expect( - ConstructorArbolAuto().ordenLocalDesde(mas.id), - ('calidad', 'x', 1), + pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), + isEmpty, ); }); }); - group( - 'ConstructorArbolAuto.itemsLocales: matriz de fallback de arte ' - '(Spec "Local Track Embedded Album Art Display")', - () { - test( - 'cache-miss (native devuelve artUri: null) cae al placeholder ' - 'rotativo, nunca queda vacío/roto', - () async { - const nodo = NodoLocal( - documentId: 'doc-miss', - nombre: 'Cancion.mp3', - esDirectorio: false, - ); - const metadatos = MetadatosPista( - titulo: 'Con titulo pero sin arte', - artUri: null, // cache-miss nativo - ); + group('ConstructorArbolAuto.itemsLocales: page-0 folder-play actions ' + '(Design ADR-5, Phase 3 task 4.1)', () { + test('folder con >=1 pista de audio directa antepone "Reproducir ' + 'carpeta" + "Reproducir aleatorio", ambas playable:true, con id ' + 'prefijado (Spec "Folder has tracks")', () async { + final nodos = [ + const NodoLocal( + documentId: 'd-a', + nombre: 'a.mp3', + esDirectorio: false, + ), + ]; - final item = (await ConstructorArbolAuto().itemsLocales( - [nodo], - documentIdPadre: 'x', - metadatosDe: (ids) async => {'doc-miss': metadatos}, - )).singleWhere((i) => i.id.startsWith('pista:')); + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + ); - expect(item.artUri.toString(), artUriLocal('doc-miss')); - expect(item.artUri.toString(), isNotEmpty); + final reproducir = pagina0.where( + (i) => i.id.startsWith('carpeta_local_reproducir:'), + ); + final aleatorio = pagina0.where( + (i) => i.id.startsWith('carpeta_local_aleatorio:'), + ); + expect(reproducir, hasLength(1)); + expect(aleatorio, hasLength(1)); + expect(reproducir.single.id, 'carpeta_local_reproducir:padre1'); + expect(reproducir.single.playable, isTrue); + expect(aleatorio.single.id, 'carpeta_local_aleatorio:padre1'); + expect(aleatorio.single.playable, isTrue); + }); + + test('folder con 0 pistas de audio directas (solo subcarpetas) NO ' + 'antepone ninguna de las 2 acciones (Spec "Folder has no tracks", ' + 'edge case de carpeta vacía)', () async { + final nodos = [ + const NodoLocal( + documentId: 'd-sub', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + ); + + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')), + isEmpty, + ); + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), + isEmpty, + ); + }); + + test('las acciones solo aparecen en página 0, nunca en páginas ' + 'siguientes', () async { + final nodos = List.generate( + 60, + (i) => NodoLocal( + documentId: 'd$i', + nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', + esDirectorio: false, + ), + ); + final builder = ConstructorArbolAuto(); + + final pagina1 = await builder.itemsLocales( + nodos, + documentIdPadre: 'padre1', + pagina: 1, + metadatosDe: _metadatosVacio, + ); + + expect( + pagina1.where((i) => i.id.startsWith('carpeta_local_reproducir:')), + isEmpty, + ); + expect( + pagina1.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), + isEmpty, + ); + }); + }); + + group('ConstructorArbolAuto.itemsLocalesOrdenCalidad', () { + test('ordena TODA la carpeta por bitrate desc vía UN batch de metadatosDe ' + '(full-folder, no page-scoped) y luego pagina', () async { + final nodos = List.generate( + 5, + (i) => NodoLocal( + documentId: 'd$i', + nombre: 'cancion_$i.mp3', + esDirectorio: false, + ), + ); + final metadatos = { + 'd0': const MetadatosPista(bitrate: 64000), + 'd1': const MetadatosPista(bitrate: 320000), + 'd2': const MetadatosPista(bitrate: 128000), + 'd3': const MetadatosPista(bitrate: 256000), + 'd4': const MetadatosPista(bitrate: 192000), + }; + var llamadas = 0; + List? idsRecibidos; + + final items = await ConstructorArbolAuto().itemsLocalesOrdenCalidad( + nodos, + documentIdPadre: 'x', + metadatosDe: (ids) async { + llamadas++; + idsRecibidos = ids; + return metadatos; }, ); - test( - 'parse-failure (entrada de metadata completamente null) cae al ' - 'placeholder rotativo, nunca queda vacío/roto', - () async { - const nodo = NodoLocal( - documentId: 'doc-fail', - nombre: 'Cancion.mp3', - esDirectorio: false, - ); - const metadatos = MetadatosPista(); // todo-null, mirrors a - // native per-entry parse failure (Design "readAudioMetadataBatch" - // — per-file try/catch -> all-null-but-documentId row). + expect(llamadas, 1); + expect(idsRecibidos, hasLength(5)); // full-folder, not page-scoped + expect(items.map((i) => i.id).toList(), [ + 'pista:d1', + 'pista:d3', + 'pista:d4', + 'pista:d2', + 'pista:d0', + ]); + }); - final item = (await ConstructorArbolAuto().itemsLocales( - [nodo], - documentIdPadre: 'x', - metadatosDe: (ids) async => {'doc-fail': metadatos}, - )).singleWhere((i) => i.id.startsWith('pista:')); - - expect(item.artUri.toString(), artUriLocal('doc-fail')); - expect(item.title, 'Cancion'); // también cae el título - }, - ); - }, - ); - - group('ConstructorArbolAuto.itemsLocalesBucket', () { test( - 'devuelve solo las pistas del bucket indicado, paginadas y con ' - 'metadata resuelta SOLO para la página', + 'paginación con "Más…" enrutado a carpeta_local_ord:calidad:', () async { - final nodos = [ - const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-g', nombre: 'Gato.mp3', esDirectorio: false), - ]; - List? idsRecibidos; - - final items = await ConstructorArbolAuto().itemsLocalesBucket( - nodos, - documentIdPadre: 'x', - idxBucket: 0, // A-F - metadatosDe: (ids) async { - idsRecibidos = ids; - return const {}; - }, + final nodos = List.generate( + 60, + (i) => NodoLocal( + documentId: 'd$i', + nombre: 'cancion_$i.mp3', + esDirectorio: false, + ), ); - expect(items, hasLength(1)); - expect(items.single.id, 'pista:d-a'); - expect(idsRecibidos, ['d-a']); + final pagina0 = await ConstructorArbolAuto().itemsLocalesOrdenCalidad( + nodos, + documentIdPadre: 'x', + metadatosDe: _metadatosVacio, + ); + + expect(pagina0, hasLength(51)); + final mas = pagina0.last; + expect(mas.title, 'Más…'); + expect(ConstructorArbolAuto().esCarpetaLocalOrdMediaId(mas.id), isTrue); + expect(ConstructorArbolAuto().ordenLocalDesde(mas.id), ( + 'calidad', + 'x', + 1, + )); }, ); + }); - test('bucket sin coincidencias devuelve lista vacía, no un error', () async { + group('ConstructorArbolAuto.itemsLocales: matriz de fallback de arte ' + '(Spec "Local Track Embedded Album Art Display")', () { + test('cache-miss (native devuelve artUri: null) cae al placeholder ' + 'rotativo, nunca queda vacío/roto', () async { + const nodo = NodoLocal( + documentId: 'doc-miss', + nombre: 'Cancion.mp3', + esDirectorio: false, + ); + const metadatos = MetadatosPista( + titulo: 'Con titulo pero sin arte', + artUri: null, // cache-miss nativo + ); + + final item = (await ConstructorArbolAuto().itemsLocales( + [nodo], + documentIdPadre: 'x', + metadatosDe: (ids) async => {'doc-miss': metadatos}, + )).singleWhere((i) => i.id.startsWith('pista:')); + + expect(item.artUri.toString(), artUriLocal('doc-miss')); + expect(item.artUri.toString(), isNotEmpty); + }); + + test('parse-failure (entrada de metadata completamente null) cae al ' + 'placeholder rotativo, nunca queda vacío/roto', () async { + const nodo = NodoLocal( + documentId: 'doc-fail', + nombre: 'Cancion.mp3', + esDirectorio: false, + ); + const metadatos = MetadatosPista(); // todo-null, mirrors a + // native per-entry parse failure (Design "readAudioMetadataBatch" + // — per-file try/catch -> all-null-but-documentId row). + + final item = (await ConstructorArbolAuto().itemsLocales( + [nodo], + documentIdPadre: 'x', + metadatosDe: (ids) async => {'doc-fail': metadatos}, + )).singleWhere((i) => i.id.startsWith('pista:')); + + expect(item.artUri.toString(), artUriLocal('doc-fail')); + expect(item.title, 'Cancion'); // también cae el título + }); + }); + + group('ConstructorArbolAuto.itemsLocalesBucket', () { + test('devuelve solo las pistas del bucket indicado, paginadas y con ' + 'metadata resuelta SOLO para la página', () async { final nodos = [ - const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), + const NodoLocal( + documentId: 'd-a', + nombre: 'Amor.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-g', + nombre: 'Gato.mp3', + esDirectorio: false, + ), ]; + List? idsRecibidos; final items = await ConstructorArbolAuto().itemsLocalesBucket( nodos, documentIdPadre: 'x', - idxBucket: 3, // T-Z, sin coincidencias - metadatosDe: _metadatosVacio, + idxBucket: 0, // A-F + metadatosDe: (ids) async { + idsRecibidos = ids; + return const {}; + }, ); - expect(items, isEmpty); + expect(items, hasLength(1)); + expect(items.single.id, 'pista:d-a'); + expect(idsRecibidos, ['d-a']); }); + test( + 'bucket sin coincidencias devuelve lista vacía, no un error', + () async { + final nodos = [ + const NodoLocal( + documentId: 'd-a', + nombre: 'Amor.mp3', + esDirectorio: false, + ), + ]; + + final items = await ConstructorArbolAuto().itemsLocalesBucket( + nodos, + documentIdPadre: 'x', + idxBucket: 3, // T-Z, sin coincidencias + metadatosDe: _metadatosVacio, + ); + + expect(items, isEmpty); + }, + ); + test('idxBucket fuera de rango devuelve lista vacía, sin lanzar', () async { final nodos = [ - const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), + const NodoLocal( + documentId: 'd-a', + nombre: 'Amor.mp3', + esDirectorio: false, + ), ]; final items = await ConstructorArbolAuto().itemsLocalesBucket( @@ -1929,315 +2092,267 @@ void main() { }, ); - test( - 'id carpeta_local: recorta el prefijo y delega en ' - 'fuente.hijos(id)', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'sub1': const [ - NodoLocal( - documentId: 'd2', - nombre: 'Otra.mp3', - esDirectorio: false, - ), - ], - }, - ); + test('id carpeta_local: recorta el prefijo y delega en ' + 'fuente.hijos(id)', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'sub1': const [ + NodoLocal( + documentId: 'd2', + nombre: 'Otra.mp3', + esDirectorio: false, + ), + ], + }, + ); - final items = await hijosMusicaLocal( - 'carpeta_local:sub1', - fuente: fuente, - ); + final items = await hijosMusicaLocal( + 'carpeta_local:sub1', + fuente: fuente, + ); - expect(items, isNotNull); - expect( - items!.singleWhere((i) => i.id.startsWith('pista:')).id, - 'pista:d2', - ); - }, - ); + expect(items, isNotNull); + expect( + items!.singleWhere((i) => i.id.startsWith('pista:')).id, + 'pista:d2', + ); + }); - test( - 'un id que no es de música local devuelve null (deja pasar al ' - 'siguiente branch del caller)', - () async { - final fuente = _FakeFuenteMusicaLocalAuto(); + test('un id que no es de música local devuelve null (deja pasar al ' + 'siguiente branch del caller)', () async { + final fuente = _FakeFuenteMusicaLocalAuto(); - expect(await hijosMusicaLocal('favoritos', fuente: fuente), isNull); - expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull); - }, - ); + expect(await hijosMusicaLocal('favoritos', fuente: fuente), isNull); + expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull); + }); - test( - 'fuente null (cold-start, nunca registrada) devuelve lista vacía ' - 'para un id de música local válido, no null y sin lanzar', - () async { - final resultado = await hijosMusicaLocal( - ConstructorArbolAuto.idMusicaLocal, - fuente: null, - ); + test('fuente null (cold-start, nunca registrada) devuelve lista vacía ' + 'para un id de música local válido, no null y sin lanzar', () async { + final resultado = await hijosMusicaLocal( + ConstructorArbolAuto.idMusicaLocal, + fuente: null, + ); - expect(resultado, isNotNull); - expect(resultado, isEmpty); - }, - ); + expect(resultado, isNotNull); + expect(resultado, isEmpty); + }); - test( - 'una fuente que lanza (permiso revocado) degrada a lista vacía, sin ' - 'propagar la excepción', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - errorEnHijos: Exception('permiso revocado'), - ); + test('una fuente que lanza (permiso revocado) degrada a lista vacía, sin ' + 'propagar la excepción', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + errorEnHijos: Exception('permiso revocado'), + ); - final resultado = await hijosMusicaLocal( - ConstructorArbolAuto.idMusicaLocal, - fuente: fuente, - ); + final resultado = await hijosMusicaLocal( + ConstructorArbolAuto.idMusicaLocal, + fuente: fuente, + ); - expect(resultado, isNotNull); - expect(resultado, isEmpty); - }, - ); + expect(resultado, isNotNull); + expect(resultado, isEmpty); + }); - test( - 'un id carpeta_local_pag:: enruta a (docId, n) y devuelve ' - 'esa página', - () async { - final nodos = List.generate( - 60, - (i) => NodoLocal( - documentId: 'd$i', - nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', - esDirectorio: false, - ), - ); - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: {'sub1': nodos}, - ); + test('un id carpeta_local_pag:: enruta a (docId, n) y devuelve ' + 'esa página', () async { + final nodos = List.generate( + 60, + (i) => NodoLocal( + documentId: 'd$i', + nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', + esDirectorio: false, + ), + ); + final fuente = _FakeFuenteMusicaLocalAuto(hijosPorDocId: {'sub1': nodos}); - final items = await hijosMusicaLocal( - 'carpeta_local_pag:1:sub1', - fuente: fuente, - ); + final items = await hijosMusicaLocal( + 'carpeta_local_pag:1:sub1', + fuente: fuente, + ); - expect(items, isNotNull); - // Página 1 (0-indexed) de 60 items con tamaño 50 trae los 10 - // restantes, sin "Más…". - expect(items, hasLength(10)); - expect(items!.any((i) => i.title == 'Más…'), isFalse); - }, - ); + expect(items, isNotNull); + // Página 1 (0-indexed) de 60 items con tamaño 50 trae los 10 + // restantes, sin "Más…". + expect(items, hasLength(10)); + expect(items!.any((i) => i.title == 'Más…'), isFalse); + }); - test( - 'un id que no es de música local (incluido uno con prefijo distinto) ' - 'devuelve null', - () async { - final fuente = _FakeFuenteMusicaLocalAuto(); + test('un id que no es de música local (incluido uno con prefijo distinto) ' + 'devuelve null', () async { + final fuente = _FakeFuenteMusicaLocalAuto(); - expect( - await hijosMusicaLocal('carpeta_local_pag_otro:1:x', fuente: fuente), - isNull, - ); - }, - ); + expect( + await hijosMusicaLocal('carpeta_local_pag_otro:1:x', fuente: fuente), + isNull, + ); + }); - test( - 'fuente null con un id paginado válido devuelve lista vacía, no null ' - 'y sin lanzar', - () async { - final resultado = await hijosMusicaLocal( - 'carpeta_local_pag:1:sub1', - fuente: null, - ); + test('fuente null con un id paginado válido devuelve lista vacía, no null ' + 'y sin lanzar', () async { + final resultado = await hijosMusicaLocal( + 'carpeta_local_pag:1:sub1', + fuente: null, + ); - expect(resultado, isNotNull); - expect(resultado, isEmpty); - }, - ); + expect(resultado, isNotNull); + expect(resultado, isEmpty); + }); - test( - 'una fuente que lanza en la rama paginada degrada a lista vacía, sin ' - 'propagar la excepción', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - errorEnHijos: Exception('permiso revocado'), - ); + test('una fuente que lanza en la rama paginada degrada a lista vacía, sin ' + 'propagar la excepción', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + errorEnHijos: Exception('permiso revocado'), + ); - final resultado = await hijosMusicaLocal( - 'carpeta_local_pag:1:sub1', - fuente: fuente, - ); + final resultado = await hijosMusicaLocal( + 'carpeta_local_pag:1:sub1', + fuente: fuente, + ); - expect(resultado, isNotNull); - expect(resultado, isEmpty); - }, - ); + expect(resultado, isNotNull); + expect(resultado, isEmpty); + }); - test( - 'un id carpeta_local_ord:calidad:: enruta a la lista ' - 'ordenada por calidad de esa carpeta', - () async { - final nodos = [ - const NodoLocal(documentId: 'd-1', nombre: 'a.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-2', nombre: 'b.mp3', esDirectorio: false), - ]; - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: {'sub1': nodos}, - metadatosPorDocId: { - 'd-1': const MetadatosPista(bitrate: 64000), - 'd-2': const MetadatosPista(bitrate: 320000), - }, - ); + test('un id carpeta_local_ord:calidad:: enruta a la lista ' + 'ordenada por calidad de esa carpeta', () async { + final nodos = [ + const NodoLocal( + documentId: 'd-1', + nombre: 'a.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-2', + nombre: 'b.mp3', + esDirectorio: false, + ), + ]; + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: {'sub1': nodos}, + metadatosPorDocId: { + 'd-1': const MetadatosPista(bitrate: 64000), + 'd-2': const MetadatosPista(bitrate: 320000), + }, + ); - final items = await hijosMusicaLocal( - 'carpeta_local_ord:calidad:0:sub1', - fuente: fuente, - ); + final items = await hijosMusicaLocal( + 'carpeta_local_ord:calidad:0:sub1', + fuente: fuente, + ); - expect(items, isNotNull); - expect(items!.map((i) => i.id).toList(), ['pista:d-2', 'pista:d-1']); - }, - ); + expect(items, isNotNull); + expect(items!.map((i) => i.id).toList(), ['pista:d-2', 'pista:d-1']); + }); - test( - 'un id carpeta_local_bucket::: enruta al bucket ' - 'correspondiente de esa carpeta', - () async { - final nodos = [ - const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), - const NodoLocal(documentId: 'd-g', nombre: 'Gato.mp3', esDirectorio: false), - ]; - final fuente = _FakeFuenteMusicaLocalAuto(hijosPorDocId: {'sub1': nodos}); + test('un id carpeta_local_bucket::: enruta al bucket ' + 'correspondiente de esa carpeta', () async { + final nodos = [ + const NodoLocal( + documentId: 'd-a', + nombre: 'Amor.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-g', + nombre: 'Gato.mp3', + esDirectorio: false, + ), + ]; + final fuente = _FakeFuenteMusicaLocalAuto(hijosPorDocId: {'sub1': nodos}); - final items = await hijosMusicaLocal( - 'carpeta_local_bucket:0:0:sub1', - fuente: fuente, - ); + final items = await hijosMusicaLocal( + 'carpeta_local_bucket:0:0:sub1', + fuente: fuente, + ); - expect(items, isNotNull); - expect(items!.single.id, 'pista:d-a'); - }, - ); + expect(items, isNotNull); + expect(items!.single.id, 'pista:d-a'); + }); - test( - 'fuente null para un id _ord o _bucket válido devuelve lista vacía, ' - 'no null y sin lanzar', - () async { - final ord = await hijosMusicaLocal( - 'carpeta_local_ord:calidad:0:sub1', - fuente: null, - ); - final bucket = await hijosMusicaLocal( - 'carpeta_local_bucket:0:0:sub1', - fuente: null, - ); + test('fuente null para un id _ord o _bucket válido devuelve lista vacía, ' + 'no null y sin lanzar', () async { + final ord = await hijosMusicaLocal( + 'carpeta_local_ord:calidad:0:sub1', + fuente: null, + ); + final bucket = await hijosMusicaLocal( + 'carpeta_local_bucket:0:0:sub1', + fuente: null, + ); - expect(ord, isNotNull); - expect(ord, isEmpty); - expect(bucket, isNotNull); - expect(bucket, isEmpty); - }, - ); + expect(ord, isNotNull); + expect(ord, isEmpty); + expect(bucket, isNotNull); + expect(bucket, isEmpty); + }); - test( - 'una fuente que lanza en la rama _ord o _bucket degrada a lista ' - 'vacía, sin propagar la excepción', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - errorEnHijos: Exception('permiso revocado'), - ); + test('una fuente que lanza en la rama _ord o _bucket degrada a lista ' + 'vacía, sin propagar la excepción', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + errorEnHijos: Exception('permiso revocado'), + ); - final ord = await hijosMusicaLocal( - 'carpeta_local_ord:calidad:0:sub1', - fuente: fuente, - ); - final bucket = await hijosMusicaLocal( - 'carpeta_local_bucket:0:0:sub1', - fuente: fuente, - ); + final ord = await hijosMusicaLocal( + 'carpeta_local_ord:calidad:0:sub1', + fuente: fuente, + ); + final bucket = await hijosMusicaLocal( + 'carpeta_local_bucket:0:0:sub1', + fuente: fuente, + ); - expect(ord, isNotNull); - expect(ord, isEmpty); - expect(bucket, isNotNull); - expect(bucket, isEmpty); - }, - ); + expect(ord, isNotNull); + expect(ord, isEmpty); + expect(bucket, isNotNull); + expect(bucket, isEmpty); + }); }); group('reproducirPistaLocal', () { - test( - 'resuelve el content uri y delega a reproducir con un MediaItem ' - 'reproducible', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - uriPorDocId: const {'doc1': 'content://provider/doc1'}, - ); - MediaItem? recibido; - - await reproducirPistaLocal( - 'pista:doc1', - fuente: fuente, - reproducir: (item) async { - recibido = item; - }, - ); - - expect(recibido, isNotNull); - expect(recibido!.id, 'content://provider/doc1'); - }, - ); - - test( - 'item 3: el MediaItem de una pista local tocada directamente usa el ' - 'fallback de marca (artUriLocal), no queda sin artUri', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - uriPorDocId: const {'doc1': 'content://provider/doc1'}, - ); - MediaItem? recibido; - - await reproducirPistaLocal( - 'pista:doc1', - fuente: fuente, - reproducir: (item) async { - recibido = item; - }, - ); - - expect(recibido!.artUri, isNotNull); - expect(recibido!.artUri.toString(), artUriLocal('doc1')); - }, - ); - - test( - 'id obsoleto/desconocido (uriContenidoDePista devuelve null) no ' - 'llama a reproducir ni lanza excepción', - () async { - final fuente = _FakeFuenteMusicaLocalAuto(); - var llamadas = 0; - - await reproducirPistaLocal( - 'pista:doc-fantasma', - fuente: fuente, - reproducir: (item) async { - llamadas++; - }, - ); - - expect(llamadas, 0); - }, - ); - - test('id sin el prefijo pista: es un no-op, no llama a reproducir', () async { + test('resuelve el content uri y delega a reproducir con un MediaItem ' + 'reproducible', () async { final fuente = _FakeFuenteMusicaLocalAuto( uriPorDocId: const {'doc1': 'content://provider/doc1'}, ); + MediaItem? recibido; + + await reproducirPistaLocal( + 'pista:doc1', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); + + expect(recibido, isNotNull); + expect(recibido!.id, 'content://provider/doc1'); + }); + + test('item 3: el MediaItem de una pista local tocada directamente usa el ' + 'fallback de marca (artUriLocal), no queda sin artUri', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + uriPorDocId: const {'doc1': 'content://provider/doc1'}, + ); + MediaItem? recibido; + + await reproducirPistaLocal( + 'pista:doc1', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); + + expect(recibido!.artUri, isNotNull); + expect(recibido!.artUri.toString(), artUriLocal('doc1')); + }); + + test('id obsoleto/desconocido (uriContenidoDePista devuelve null) no ' + 'llama a reproducir ni lanza excepción', () async { + final fuente = _FakeFuenteMusicaLocalAuto(); var llamadas = 0; await reproducirPistaLocal( - 'emisora:doc1', + 'pista:doc-fantasma', fuente: fuente, reproducir: (item) async { llamadas++; @@ -2248,29 +2363,46 @@ void main() { }); test( - 'regresión EQ (estructural): reproducirPistaLocal expone el mismo ' - 'seam `reproducir` que reproducirPorMediaId usa para playMediaItem — ' - 'no existe un parámetro/seam alternativo para música local, por lo ' - 'que la reproducción local pasa por la MISMA cadena de EQ', + 'id sin el prefijo pista: es un no-op, no llama a reproducir', () async { final fuente = _FakeFuenteMusicaLocalAuto( uriPorDocId: const {'doc1': 'content://provider/doc1'}, ); - final llamadasReproducir = []; + var llamadas = 0; await reproducirPistaLocal( - 'pista:doc1', + 'emisora:doc1', fuente: fuente, - reproducir: (item) async => llamadasReproducir.add(item), - // NOTE: no existe ningún parámetro alternativo de reproducción — - // esa ausencia ES la prueba de que no hay un camino separado que - // evite el EQ (mismo patrón que el test estructural de - // aplicarPresetPorMediaId). + reproducir: (item) async { + llamadas++; + }, ); - expect(llamadasReproducir, hasLength(1)); + expect(llamadas, 0); }, ); + + test('regresión EQ (estructural): reproducirPistaLocal expone el mismo ' + 'seam `reproducir` que reproducirPorMediaId usa para playMediaItem — ' + 'no existe un parámetro/seam alternativo para música local, por lo ' + 'que la reproducción local pasa por la MISMA cadena de EQ', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + uriPorDocId: const {'doc1': 'content://provider/doc1'}, + ); + final llamadasReproducir = []; + + await reproducirPistaLocal( + 'pista:doc1', + fuente: fuente, + reproducir: (item) async => llamadasReproducir.add(item), + // NOTE: no existe ningún parámetro alternativo de reproducción — + // esa ausencia ES la prueba de que no hay un camino separado que + // evite el EQ (mismo patrón que el test estructural de + // aplicarPresetPorMediaId). + ); + + expect(llamadasReproducir, hasLength(1)); + }); }); group('reproducirCarpetaLocal (Design ADR-5/ADR-6, Phase 3 task 4.2)', () { @@ -2285,58 +2417,54 @@ void main() { ), ]; - test( - 'secuencial (aleatorio: false): filtra directorios, ordena por ' - 'nombre y llama iniciarCola con esa lista', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: {'carpeta1': nodosDePrueba()}, - ); - List? recibidas; + test('secuencial (aleatorio: false): filtra directorios, ordena por ' + 'nombre y llama iniciarCola con esa lista', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: {'carpeta1': nodosDePrueba()}, + ); + List? recibidas; - await reproducirCarpetaLocal( - 'carpeta_local_reproducir:carpeta1', - aleatorio: false, - fuente: fuente, - iniciarCola: (pistas) async => recibidas = pistas, - ); + await reproducirCarpetaLocal( + 'carpeta_local_reproducir:carpeta1', + aleatorio: false, + fuente: fuente, + iniciarCola: (pistas) async => recibidas = pistas, + ); - expect(recibidas, isNotNull); - expect( - recibidas!.map((n) => n.documentId).toList(), - ['d-a', 'd-b', 'd-c'], - ); - }, - ); + expect(recibidas, isNotNull); + expect(recibidas!.map((n) => n.documentId).toList(), [ + 'd-a', + 'd-b', + 'd-c', + ]); + }); - test( - 'aleatorio (aleatorio: true): filtra directorios y llama iniciarCola ' - 'con el orden Fisher-Yates producido por el rng inyectado', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: {'carpeta1': nodosDePrueba()}, - ); - List? recibidas; + test('aleatorio (aleatorio: true): filtra directorios y llama iniciarCola ' + 'con el orden Fisher-Yates producido por el rng inyectado', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: {'carpeta1': nodosDePrueba()}, + ); + List? recibidas; - await reproducirCarpetaLocal( - 'carpeta_local_aleatorio:carpeta1', - aleatorio: true, - fuente: fuente, - rng: Random(42), - iniciarCola: (pistas) async => recibidas = pistas, - ); + await reproducirCarpetaLocal( + 'carpeta_local_aleatorio:carpeta1', + aleatorio: true, + fuente: fuente, + rng: Random(42), + iniciarCola: (pistas) async => recibidas = pistas, + ); - expect(recibidas, isNotNull); - expect(recibidas, hasLength(3)); - expect(recibidas!.every((n) => !n.esDirectorio), isTrue); - expect( - recibidas!.map((n) => n.documentId).toList(), - pistasEnOrdenAleatorio(nodosDePrueba(), Random(42)) - .map((n) => n.documentId) - .toList(), - ); - }, - ); + expect(recibidas, isNotNull); + expect(recibidas, hasLength(3)); + expect(recibidas!.every((n) => !n.esDirectorio), isTrue); + expect( + recibidas!.map((n) => n.documentId).toList(), + pistasEnOrdenAleatorio( + nodosDePrueba(), + Random(42), + ).map((n) => n.documentId).toList(), + ); + }); test( 'carpeta vacía (sin hijos) es un no-op: iniciarCola nunca se llama', @@ -2355,91 +2483,82 @@ void main() { }, ); - test( - 'carpeta con solo subcarpetas (sin pistas de audio directas), y esa ' - 'subcarpeta TAMBIÉN está vacía incluso recursivamente, es un no-op: ' - 'iniciarCola nunca se llama', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'carpeta1': const [ - NodoLocal( - documentId: 'd-sub', - nombre: 'Subcarpeta', - esDirectorio: true, - ), - ], - }, - ); - var llamadas = 0; + test('carpeta con solo subcarpetas (sin pistas de audio directas), y esa ' + 'subcarpeta TAMBIÉN está vacía incluso recursivamente, es un no-op: ' + 'iniciarCola nunca se llama', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'carpeta1': const [ + NodoLocal( + documentId: 'd-sub', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ], + }, + ); + var llamadas = 0; - await reproducirCarpetaLocal( - 'carpeta_local_reproducir:carpeta1', - aleatorio: false, - fuente: fuente, - iniciarCola: (pistas) async => llamadas++, - ); + await reproducirCarpetaLocal( + 'carpeta_local_reproducir:carpeta1', + aleatorio: false, + fuente: fuente, + iniciarCola: (pistas) async => llamadas++, + ); - expect(llamadas, 0); - }, - ); + expect(llamadas, 0); + }); - test( - 'item 2: carpeta con SOLO una subcarpeta que sí tiene pistas -- las ' - 'recolecta RECURSIVAMENTE y llama iniciarCola con ellas (antes era ' - 'un no-op)', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'carpeta1': const [ - NodoLocal( - documentId: 'd-sub', - nombre: 'Subcarpeta', - esDirectorio: true, - ), - ], - 'd-sub': const [ - NodoLocal( - documentId: 'd-honda', - nombre: 'honda.mp3', - esDirectorio: false, - ), - ], - }, - ); - List? recibidas; + test('item 2: carpeta con SOLO una subcarpeta que sí tiene pistas -- las ' + 'recolecta RECURSIVAMENTE y llama iniciarCola con ellas (antes era ' + 'un no-op)', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'carpeta1': const [ + NodoLocal( + documentId: 'd-sub', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ], + 'd-sub': const [ + NodoLocal( + documentId: 'd-honda', + nombre: 'honda.mp3', + esDirectorio: false, + ), + ], + }, + ); + List? recibidas; - await reproducirCarpetaLocal( - 'carpeta_local_reproducir:carpeta1', - aleatorio: false, - fuente: fuente, - iniciarCola: (pistas) async => recibidas = pistas, - ); + await reproducirCarpetaLocal( + 'carpeta_local_reproducir:carpeta1', + aleatorio: false, + fuente: fuente, + iniciarCola: (pistas) async => recibidas = pistas, + ); - expect(recibidas, isNotNull); - expect(recibidas!.map((n) => n.documentId).toList(), ['d-honda']); - }, - ); + expect(recibidas, isNotNull); + expect(recibidas!.map((n) => n.documentId).toList(), ['d-honda']); + }); - test( - 'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar ' - 'la excepción', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - errorEnHijos: Exception('permiso revocado'), - ); - var llamadas = 0; + test('carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar ' + 'la excepción', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + errorEnHijos: Exception('permiso revocado'), + ); + var llamadas = 0; - await reproducirCarpetaLocal( - 'carpeta_local_reproducir:carpeta1', - aleatorio: false, - fuente: fuente, - iniciarCola: (pistas) async => llamadas++, - ); + await reproducirCarpetaLocal( + 'carpeta_local_reproducir:carpeta1', + aleatorio: false, + fuente: fuente, + iniciarCola: (pistas) async => llamadas++, + ); - expect(llamadas, 0); - }, - ); + expect(llamadas, 0); + }); test('id sin ninguno de los 2 prefijos es un no-op', () async { final fuente = _FakeFuenteMusicaLocalAuto( @@ -2457,86 +2576,74 @@ void main() { expect(llamadas, 0); }); - test( - 'strip por longitud preserva un documentId con ":" verbatim (round ' - 'trip), consultando fuente.hijos con el documentId correcto', - () async { - const docId = 'primary:Music/Local Folder'; - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: {docId: nodosDePrueba()}, - ); - var llamadas = 0; + test('strip por longitud preserva un documentId con ":" verbatim (round ' + 'trip), consultando fuente.hijos con el documentId correcto', () async { + const docId = 'primary:Music/Local Folder'; + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: {docId: nodosDePrueba()}, + ); + var llamadas = 0; - await reproducirCarpetaLocal( - 'carpeta_local_reproducir:$docId', - aleatorio: false, - fuente: fuente, - iniciarCola: (pistas) async => llamadas++, - ); + await reproducirCarpetaLocal( + 'carpeta_local_reproducir:$docId', + aleatorio: false, + fuente: fuente, + iniciarCola: (pistas) async => llamadas++, + ); - expect(llamadas, 1); - }, - ); + expect(llamadas, 1); + }); }); group('construirMediaItemColaLocal', () { - test( - 'resuelve el content uri y construye un MediaItem con el título ' - 'derivado del documentId', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - uriPorDocId: const {'doc1': 'content://provider/doc1'}, - ); - const nodo = NodoLocal( - documentId: 'doc1', - nombre: 'ignorado.mp3', - esDirectorio: false, - ); + test('resuelve el content uri y construye un MediaItem con el título ' + 'derivado del documentId', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + uriPorDocId: const {'doc1': 'content://provider/doc1'}, + ); + const nodo = NodoLocal( + documentId: 'doc1', + nombre: 'ignorado.mp3', + esDirectorio: false, + ); - final item = await construirMediaItemColaLocal(nodo, fuente: fuente); + final item = await construirMediaItemColaLocal(nodo, fuente: fuente); - expect(item, isNotNull); - expect(item!.id, 'content://provider/doc1'); - }, - ); + expect(item, isNotNull); + expect(item!.id, 'content://provider/doc1'); + }); - test( - 'documentId irresoluble (uriContenidoDePista devuelve null) ' - 'devuelve null, sin lanzar', - () async { - final fuente = _FakeFuenteMusicaLocalAuto(); - const nodo = NodoLocal( - documentId: 'doc-fantasma', - nombre: 'x.mp3', - esDirectorio: false, - ); + test('documentId irresoluble (uriContenidoDePista devuelve null) ' + 'devuelve null, sin lanzar', () async { + final fuente = _FakeFuenteMusicaLocalAuto(); + const nodo = NodoLocal( + documentId: 'doc-fantasma', + nombre: 'x.mp3', + esDirectorio: false, + ); - final item = await construirMediaItemColaLocal(nodo, fuente: fuente); + final item = await construirMediaItemColaLocal(nodo, fuente: fuente); - expect(item, isNull); - }, - ); + expect(item, isNull); + }); - test( - 'item 3: el MediaItem de una pista en cola de "Reproducir carpeta" ' - 'usa el fallback de marca (artUriLocal), no queda sin artUri (antes ' - 'una pista en cola no tenía NINGÚN arte)', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - uriPorDocId: const {'doc1': 'content://provider/doc1'}, - ); - const nodo = NodoLocal( - documentId: 'doc1', - nombre: 'ignorado.mp3', - esDirectorio: false, - ); + test('item 3: el MediaItem de una pista en cola de "Reproducir carpeta" ' + 'usa el fallback de marca (artUriLocal), no queda sin artUri (antes ' + 'una pista en cola no tenía NINGÚN arte)', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + uriPorDocId: const {'doc1': 'content://provider/doc1'}, + ); + const nodo = NodoLocal( + documentId: 'doc1', + nombre: 'ignorado.mp3', + esDirectorio: false, + ); - final item = await construirMediaItemColaLocal(nodo, fuente: fuente); + final item = await construirMediaItemColaLocal(nodo, fuente: fuente); - expect(item!.artUri, isNotNull); - expect(item.artUri.toString(), artUriLocal('doc1')); - }, - ); + expect(item!.artUri, isNotNull); + expect(item.artUri.toString(), artUriLocal('doc1')); + }); }); group('ConstructorArbolAuto.itemEmisora', () { @@ -2626,7 +2733,9 @@ void main() { expect(resultado, isNot(equals(' '))); expect( resultado, - startsWith('android.resource://es.freetimelab.pluriwave/drawable/station_art_'), + startsWith( + 'android.resource://es.freetimelab.pluriwave/drawable/station_art_', + ), ); }); }); @@ -2685,21 +2794,15 @@ void main() { 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(); + 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, - ); - }, - ); + expect(builder.resolver('favoritos', universo), isNull); + expect(builder.resolver('emisora:', universo), isNull); + expect(builder.resolver('emisora:uuid-desconocido', universo), isNull); + }); }); group('ConstructorArbolAuto.esCarpetaGrupo', () { @@ -2738,7 +2841,11 @@ void main() { final resultado = builder.carpetasFavoritos( grupos: const [ - GrupoFavoritos(id: GrupoFavoritos.sinAsignarId, nombre: 'Sin asignar', orden: 0), + GrupoFavoritos( + id: GrupoFavoritos.sinAsignarId, + nombre: 'Sin asignar', + orden: 0, + ), ], favoritos: favoritos, ); @@ -2747,7 +2854,10 @@ void main() { emisoras: favoritos, ); - expect(resultado.map((i) => i.id).toList(), esperado.map((i) => i.id).toList()); + expect( + resultado.map((i) => i.id).toList(), + esperado.map((i) => i.id).toList(), + ); expect(resultado.length, esperado.length); for (var i = 0; i < resultado.length; i++) { expect(resultado[i].id, esperado[i].id); @@ -2864,33 +2974,30 @@ void main() { }); }); 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; + 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; - }, - ); + 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); - }, - ); + 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', @@ -2910,34 +3017,31 @@ void main() { }, ); - test( - 'item 3: emisora SIN favicon usable -- el MediaItem "en reproducción" ' - 'usa el fallback de marca (artUriPara), no queda con artUri null ' - '(antes se perdía el arte al reproducir desde el auto)', - () async { - final emisora = _emisora( - uuid: 'uuid-sin-arte', - nombre: 'Radio sin logo', - favicon: null, - ); - final fuente = _FakeFuenteEmisorasAuto( - porUuidResultado: {emisora.uuid: emisora}, - ); - MediaItem? recibido; + test('item 3: emisora SIN favicon usable -- el MediaItem "en reproducción" ' + 'usa el fallback de marca (artUriPara), no queda con artUri null ' + '(antes se perdía el arte al reproducir desde el auto)', () async { + final emisora = _emisora( + uuid: 'uuid-sin-arte', + nombre: 'Radio sin logo', + favicon: null, + ); + final fuente = _FakeFuenteEmisorasAuto( + porUuidResultado: {emisora.uuid: emisora}, + ); + MediaItem? recibido; - await reproducirPorMediaId( - 'emisora:${emisora.uuid}', - fuente: fuente, - reproducir: (item) async { - recibido = item; - }, - ); + await reproducirPorMediaId( + 'emisora:${emisora.uuid}', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); - expect(recibido, isNotNull); - expect(recibido!.artUri, isNotNull); - expect(recibido!.artUri.toString(), artUriPara(emisora)); - }, - ); + expect(recibido, isNotNull); + expect(recibido!.artUri, isNotNull); + expect(recibido!.artUri.toString(), artUriPara(emisora)); + }); test( 'item 3: emisora CON favicon http(s) usable -- el MediaItem "en ' @@ -2988,10 +3092,7 @@ void main() { esDirectorio: true, ); - expect( - compararNodoLocalParaNavegacion(carpeta, archivo), - lessThan(0), - ); + expect(compararNodoLocalParaNavegacion(carpeta, archivo), lessThan(0)); expect( compararNodoLocalParaNavegacion(archivo, carpeta), greaterThan(0), @@ -3046,79 +3147,76 @@ void main() { }, ); - group( - 'ConstructorArbolAuto.itemsLocales: subfolders sort before files ' - '(Android Auto subfolder-visibility bug)', - () { - test( - 'a subfolder whose name sorts AFTER every one of 80 numbered ' - 'tracks (e.g. "Live") still lands on page 0 -- pure alphabetical ' - 'mixing used to push it to a later page, making it unreachable ' - 'without paging through every track first (the reported bug)', - () async { - final nodos = [ - for (var i = 1; i <= 80; i++) - NodoLocal( - documentId: 'track-$i', - nombre: '${i.toString().padLeft(3, '0')} Track.mp3', - esDirectorio: false, - ), - const NodoLocal( - documentId: 'folder-live', - nombre: 'Live', - esDirectorio: true, + group('ConstructorArbolAuto.itemsLocales: subfolders sort before files ' + '(Android Auto subfolder-visibility bug)', () { + test( + 'a subfolder whose name sorts AFTER every one of 80 numbered ' + 'tracks (e.g. "Live") still lands on page 0 -- pure alphabetical ' + 'mixing used to push it to a later page, making it unreachable ' + 'without paging through every track first (the reported bug)', + () async { + final nodos = [ + for (var i = 1; i <= 80; i++) + NodoLocal( + documentId: 'track-$i', + nombre: '${i.toString().padLeft(3, '0')} Track.mp3', + esDirectorio: false, ), - ]; + const NodoLocal( + documentId: 'folder-live', + nombre: 'Live', + esDirectorio: true, + ), + ]; - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'root', - metadatosDe: _metadatosVacio, - ); + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'root', + metadatosDe: _metadatosVacio, + ); - expect( - pagina0.where((i) => i.id == 'carpeta_local:folder-live'), - hasLength(1), - reason: - 'the "Live" subfolder must be visible on the FIRST page, ' - 'not hidden behind 80 tracks on a later page', - ); - }, + expect( + pagina0.where((i) => i.id == 'carpeta_local:folder-live'), + hasLength(1), + reason: + 'the "Live" subfolder must be visible on the FIRST page, ' + 'not hidden behind 80 tracks on a later page', + ); + }, + ); + + test('mixed directories and files: ALL directories precede ALL files, ' + 'each group still alphabetical by name', () async { + final nodos = [ + const NodoLocal( + documentId: 'f-z', + nombre: 'zzz.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-z', + nombre: 'ZZZ Carpeta', + esDirectorio: true, + ), + const NodoLocal( + documentId: 'f-a', + nombre: 'aaa.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-a', + nombre: 'AAA Carpeta', + esDirectorio: true, + ), + ]; + + final items = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'root', + metadatosDe: _metadatosVacio, ); - - test( - 'mixed directories and files: ALL directories precede ALL files, ' - 'each group still alphabetical by name', - () async { - final nodos = [ - const NodoLocal( - documentId: 'f-z', - nombre: 'zzz.mp3', - esDirectorio: false, - ), - const NodoLocal( - documentId: 'd-z', - nombre: 'ZZZ Carpeta', - esDirectorio: true, - ), - const NodoLocal( - documentId: 'f-a', - nombre: 'aaa.mp3', - esDirectorio: false, - ), - const NodoLocal( - documentId: 'd-a', - nombre: 'AAA Carpeta', - esDirectorio: true, - ), - ]; - - final items = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'root', - metadatosDe: _metadatosVacio, - ); - final soloArbol = items + final soloArbol = + items .where( (i) => i.id.startsWith('carpeta_local:') || @@ -3126,16 +3224,14 @@ void main() { ) .toList(); - expect(soloArbol.map((i) => i.id).toList(), [ - 'carpeta_local:d-a', - 'carpeta_local:d-z', - 'pista:f-a', - 'pista:f-z', - ]); - }, - ); - }, - ); + expect(soloArbol.map((i) => i.id).toList(), [ + 'carpeta_local:d-a', + 'carpeta_local:d-z', + 'pista:f-a', + 'pista:f-z', + ]); + }); + }); // ── Item 2: Android Auto recursive folder play ───────────────────────── // totalPistas used to count only DIRECT audio children, so "Reproducir @@ -3144,355 +3240,318 @@ void main() { // bounded recursive walk collects every track beneath a folder, and the // play actions are offered whenever that recursive count is > 0. group('pistasRecursivas (Design "recursive folder play", item 2)', () { - test( - 'flat folder (no subfolders): same tracks as the direct-children-' - 'only view, sorted by name', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'root': const [ - NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false), - NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false), - NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false), - ], - }, - ); - - final resultado = await pistasRecursivas('root', fuente: fuente); - - expect( - resultado.map((n) => n.documentId).toList(), - ['d-a', 'd-b', 'd-c'], - ); - }, - ); - - test( - 'a folder that contains ONLY subfolders: recursively collects the ' - 'tracks from every one of them', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'root': const [ - NodoLocal(documentId: 'sub-a', nombre: 'A', esDirectorio: true), - NodoLocal(documentId: 'sub-b', nombre: 'B', esDirectorio: true), - ], - 'sub-a': const [ - NodoLocal(documentId: 't1', nombre: 'uno.mp3', esDirectorio: false), - ], - 'sub-b': const [ - NodoLocal(documentId: 't2', nombre: 'dos.mp3', esDirectorio: false), - ], - }, - ); - - final resultado = await pistasRecursivas('root', fuente: fuente); - - expect(resultado.map((n) => n.documentId).toSet(), {'t1', 't2'}); - }, - ); - - test( - 'a nested tree deeper than one level: finds a track 3 levels below ' - 'the tapped folder', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'root': const [ - NodoLocal( - documentId: 'nivel1', - nombre: 'Nivel1', - esDirectorio: true, - ), - ], - 'nivel1': const [ - NodoLocal( - documentId: 'nivel2', - nombre: 'Nivel2', - esDirectorio: true, - ), - ], - 'nivel2': const [ - NodoLocal( - documentId: 'nivel3', - nombre: 'Nivel3', - esDirectorio: true, - ), - ], - 'nivel3': const [ - NodoLocal( - documentId: 't-hondo', - nombre: 'hondo.mp3', - esDirectorio: false, - ), - ], - }, - ); - - final resultado = await pistasRecursivas('root', fuente: fuente); - - expect(resultado.map((n) => n.documentId).toList(), ['t-hondo']); - }, - ); - - test( - 'depth bound: a track beyond profundidadMaxima levels is NOT ' - 'collected', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'root': const [ - NodoLocal(documentId: 'n1', nombre: 'N1', esDirectorio: true), - ], - 'n1': const [ - NodoLocal(documentId: 'n2', nombre: 'N2', esDirectorio: true), - ], - 'n2': const [ - NodoLocal( - documentId: 't-hondo', - nombre: 'hondo.mp3', - esDirectorio: false, - ), - ], - }, - ); - - // profundidadMaxima: 1 allows root -> n1 (1 hop) but NOT n1 -> n2 - // (a 2nd hop), so the track inside n2 is never reached. - final resultado = await pistasRecursivas( - 'root', - fuente: fuente, - profundidadMaxima: 1, - ); - - expect(resultado, isEmpty); - }, - ); - - test( - 'count bound: stops collecting once limite tracks are gathered, ' - 'even if more exist', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'root': List.generate( - 10, - (i) => NodoLocal( - documentId: 't$i', - nombre: 'cancion_$i.mp3', - esDirectorio: false, - ), - ), - }, - ); - - final resultado = await pistasRecursivas( - 'root', - fuente: fuente, - limite: 3, - ); - - expect(resultado, hasLength(3)); - }, - ); - - test( - 'a subfolder that fails to resolve (revoked permission) is skipped; ' - 'sibling subfolders are still visited, never throws', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'root': const [ - NodoLocal( - documentId: 'sub-mala', - nombre: 'A-Mala', - esDirectorio: true, - ), - NodoLocal( - documentId: 'sub-buena', - nombre: 'B-Buena', - esDirectorio: true, - ), - ], - 'sub-buena': const [ - NodoLocal(documentId: 't-ok', nombre: 'ok.mp3', esDirectorio: false), - ], - }, - idsConErrorEnHijos: const {'sub-mala'}, - ); - - final resultado = await pistasRecursivas('root', fuente: fuente); - - expect(resultado.map((n) => n.documentId).toList(), ['t-ok']); - }, - ); - - test('carpeta vacía (sin hijos en absoluto) devuelve lista vacía', () async { - final fuente = _FakeFuenteMusicaLocalAuto(); - - final resultado = await pistasRecursivas('root', fuente: fuente); - - expect(resultado, isEmpty); - }); - }); - - group( - 'ConstructorArbolAuto.itemsLocales: recursive folder-play gate ' - '(item 2)', - () { - test( - 'folder with ONLY subfolders, but a subfolder recursively ' - 'contains a track (fuente injected): DOES prepend both play ' - 'actions', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'sub1': const [ - NodoLocal( - documentId: 't-hondo', - nombre: 'hondo.mp3', - esDirectorio: false, - ), - ], - }, - ); - final nodos = [ - const NodoLocal( - documentId: 'sub1', - nombre: 'Subcarpeta', - esDirectorio: true, - ), - ]; - - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'padre1', - metadatosDe: _metadatosVacio, - fuente: fuente, - ); - - expect( - pagina0.where( - (i) => i.id.startsWith('carpeta_local_reproducir:'), - ), - hasLength(1), - ); - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), - hasLength(1), - ); + test('flat folder (no subfolders): same tracks as the direct-children-' + 'only view, sorted by name', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false), + NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false), + NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false), + ], }, ); - test( - 'folder with only subfolders, nested 2 levels deep, track only ' - 'at the deepest level (fuente injected): still prepends both ' - 'actions', - () async { - final fuente = _FakeFuenteMusicaLocalAuto( - hijosPorDocId: { - 'nivel1': const [ - NodoLocal( - documentId: 'nivel2', - nombre: 'N2', - esDirectorio: true, - ), - ], - 'nivel2': const [ - NodoLocal( - documentId: 't-hondo', - nombre: 'hondo.mp3', - esDirectorio: false, - ), - ], - }, - ); - final nodos = [ - const NodoLocal( + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado.map((n) => n.documentId).toList(), [ + 'd-a', + 'd-b', + 'd-c', + ]); + }); + + test('a folder that contains ONLY subfolders: recursively collects the ' + 'tracks from every one of them', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal(documentId: 'sub-a', nombre: 'A', esDirectorio: true), + NodoLocal(documentId: 'sub-b', nombre: 'B', esDirectorio: true), + ], + 'sub-a': const [ + NodoLocal(documentId: 't1', nombre: 'uno.mp3', esDirectorio: false), + ], + 'sub-b': const [ + NodoLocal(documentId: 't2', nombre: 'dos.mp3', esDirectorio: false), + ], + }, + ); + + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado.map((n) => n.documentId).toSet(), {'t1', 't2'}); + }); + + test('a nested tree deeper than one level: finds a track 3 levels below ' + 'the tapped folder', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal( documentId: 'nivel1', nombre: 'Nivel1', esDirectorio: true, ), - ]; - - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'padre1', - metadatosDe: _metadatosVacio, - fuente: fuente, - ); - - expect( - pagina0.where( - (i) => i.id.startsWith('carpeta_local_reproducir:'), - ), - hasLength(1), - ); - }, - ); - - test( - 'folder with only subfolders that are genuinely empty even ' - 'recursively (fuente injected): still does NOT prepend', - () async { - final fuente = _FakeFuenteMusicaLocalAuto(); - final nodos = [ - const NodoLocal( - documentId: 'd-sub', - nombre: 'Subcarpeta', + ], + 'nivel1': const [ + NodoLocal( + documentId: 'nivel2', + nombre: 'Nivel2', esDirectorio: true, ), - ]; - - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'padre1', - metadatosDe: _metadatosVacio, - fuente: fuente, - ); - - expect( - pagina0.where( - (i) => i.id.startsWith('carpeta_local_reproducir:'), + ], + 'nivel2': const [ + NodoLocal( + documentId: 'nivel3', + nombre: 'Nivel3', + esDirectorio: true, ), - isEmpty, - ); - expect( - pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), - isEmpty, - ); + ], + 'nivel3': const [ + NodoLocal( + documentId: 't-hondo', + nombre: 'hondo.mp3', + esDirectorio: false, + ), + ], }, ); - test( - 'without a fuente (omitted): falls back to direct-count-only ' + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado.map((n) => n.documentId).toList(), ['t-hondo']); + }); + + test('depth bound: a track beyond profundidadMaxima levels is NOT ' + 'collected', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal(documentId: 'n1', nombre: 'N1', esDirectorio: true), + ], + 'n1': const [ + NodoLocal(documentId: 'n2', nombre: 'N2', esDirectorio: true), + ], + 'n2': const [ + NodoLocal( + documentId: 't-hondo', + nombre: 'hondo.mp3', + esDirectorio: false, + ), + ], + }, + ); + + // profundidadMaxima: 1 allows root -> n1 (1 hop) but NOT n1 -> n2 + // (a 2nd hop), so the track inside n2 is never reached. + final resultado = await pistasRecursivas( + 'root', + fuente: fuente, + profundidadMaxima: 1, + ); + + expect(resultado, isEmpty); + }); + + test('count bound: stops collecting once limite tracks are gathered, ' + 'even if more exist', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': List.generate( + 10, + (i) => NodoLocal( + documentId: 't$i', + nombre: 'cancion_$i.mp3', + esDirectorio: false, + ), + ), + }, + ); + + final resultado = await pistasRecursivas( + 'root', + fuente: fuente, + limite: 3, + ); + + expect(resultado, hasLength(3)); + }); + + test('a subfolder that fails to resolve (revoked permission) is skipped; ' + 'sibling subfolders are still visited, never throws', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal( + documentId: 'sub-mala', + nombre: 'A-Mala', + esDirectorio: true, + ), + NodoLocal( + documentId: 'sub-buena', + nombre: 'B-Buena', + esDirectorio: true, + ), + ], + 'sub-buena': const [ + NodoLocal( + documentId: 't-ok', + nombre: 'ok.mp3', + esDirectorio: false, + ), + ], + }, + idsConErrorEnHijos: const {'sub-mala'}, + ); + + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado.map((n) => n.documentId).toList(), ['t-ok']); + }); + + test( + 'carpeta vacía (sin hijos en absoluto) devuelve lista vacía', + () async { + final fuente = _FakeFuenteMusicaLocalAuto(); + + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado, isEmpty); + }, + ); + }); + + group('ConstructorArbolAuto.itemsLocales: recursive folder-play gate ' + '(item 2)', () { + test('folder with ONLY subfolders, but a subfolder recursively ' + 'contains a track (fuente injected): DOES prepend both play ' + 'actions', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'sub1': const [ + NodoLocal( + documentId: 't-hondo', + nombre: 'hondo.mp3', + esDirectorio: false, + ), + ], + }, + ); + final nodos = [ + const NodoLocal( + documentId: 'sub1', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + fuente: fuente, + ); + + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')), + hasLength(1), + ); + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), + hasLength(1), + ); + }); + + test('folder with only subfolders, nested 2 levels deep, track only ' + 'at the deepest level (fuente injected): still prepends both ' + 'actions', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'nivel1': const [ + NodoLocal(documentId: 'nivel2', nombre: 'N2', esDirectorio: true), + ], + 'nivel2': const [ + NodoLocal( + documentId: 't-hondo', + nombre: 'hondo.mp3', + esDirectorio: false, + ), + ], + }, + ); + final nodos = [ + const NodoLocal( + documentId: 'nivel1', + nombre: 'Nivel1', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + fuente: fuente, + ); + + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')), + hasLength(1), + ); + }); + + test('folder with only subfolders that are genuinely empty even ' + 'recursively (fuente injected): still does NOT prepend', () async { + final fuente = _FakeFuenteMusicaLocalAuto(); + final nodos = [ + const NodoLocal( + documentId: 'd-sub', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + fuente: fuente, + ); + + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')), + isEmpty, + ); + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), + isEmpty, + ); + }); + + test('without a fuente (omitted): falls back to direct-count-only ' 'gating -- folder with only subfolders never prepends, matching ' - 'the pre-item-2 behaviour', - () async { - final nodos = [ - const NodoLocal( - documentId: 'd-sub', - nombre: 'Subcarpeta', - esDirectorio: true, - ), - ]; + 'the pre-item-2 behaviour', () async { + final nodos = [ + const NodoLocal( + documentId: 'd-sub', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ]; - final pagina0 = await ConstructorArbolAuto().itemsLocales( - nodos, - documentIdPadre: 'padre1', - metadatosDe: _metadatosVacio, - ); - - expect( - pagina0.where( - (i) => i.id.startsWith('carpeta_local_reproducir:'), - ), - isEmpty, - ); - }, + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, ); - }, - ); + + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')), + isEmpty, + ); + }); + }); } /// Fake `metadatosDe` that always resolves to an empty map — used by every diff --git a/test/servicios/servicio_audio_eq_custom_actions_test.dart b/test/servicios/servicio_audio_eq_custom_actions_test.dart index 2de6d45..05460f0 100644 --- a/test/servicios/servicio_audio_eq_custom_actions_test.dart +++ b/test/servicios/servicio_audio_eq_custom_actions_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:ui' show Locale; import 'package:audio_service/audio_service.dart'; @@ -19,14 +20,8 @@ void main() { group('presetSiguiente (item 4 — cycling presets)', () { test('advances to the next preset in order', () { - expect( - presetSiguiente(PresetEcualizador.flat), - PresetEcualizador.rock, - ); - expect( - presetSiguiente(PresetEcualizador.rock), - PresetEcualizador.pop, - ); + expect(presetSiguiente(PresetEcualizador.flat), PresetEcualizador.rock); + expect(presetSiguiente(PresetEcualizador.rock), PresetEcualizador.pop); }); test('wraps around after the last preset', () { @@ -36,21 +31,15 @@ void main() { ); }); - test( - 'an unknown/custom preset (e.g. a user-tweaked "Personalizado" band ' - 'set) starts from the FIRST preset instead of throwing', - () { - final personalizado = PresetEcualizador( - nombre: 'Personalizado', - bandas: [1.0, 2.0, 3.0, 4.0, 5.0], - ); + test('an unknown/custom preset (e.g. a user-tweaked "Personalizado" band ' + 'set) starts from the FIRST preset instead of throwing', () { + final personalizado = PresetEcualizador( + nombre: 'Personalizado', + bandas: [1.0, 2.0, 3.0, 4.0, 5.0], + ); - expect( - presetSiguiente(personalizado), - PresetEcualizador.presets.first, - ); - }, - ); + expect(presetSiguiente(personalizado), PresetEcualizador.presets.first); + }); test('respects an injected presets list instead of the default 6', () { final propios = [PresetEcualizador.jazz, PresetEcualizador.voz]; @@ -84,7 +73,10 @@ void main() { }); test('an unrecognized name falls through verbatim', () { - expect(nombrePresetVisible(l10n, 'Mi Preset Guardado'), 'Mi Preset Guardado'); + expect( + nombrePresetVisible(l10n, 'Mi Preset Guardado'), + 'Mi Preset Guardado', + ); }); }); @@ -93,35 +85,32 @@ void main() { final controles = controlesEcualizadorPersonalizados( disponible: false, activo: true, - presetActual: PresetEcualizador.flat, l10n: l10n, ); expect(controles, isEmpty); }); - test( - 'exactly 2 custom actions when available: on/off toggle + preset ' - 'cycle -- Android Auto shows a limited number of custom actions, so ' - 'this is deliberately NOT one action per preset', - () { - final controles = controlesEcualizadorPersonalizados( - disponible: true, - activo: true, - presetActual: PresetEcualizador.rock, - l10n: l10n, - ); + test('exactly 1 custom action when available: the on/off toggle -- ' + 'decision `auto/ecualizador-diseno` REMOVES the preset-cycling ' + 'action that used to sit alongside it; preset selection now lives ' + 'in the "Ecualizador" browsable folder instead (see ' + '`itemsEcualizadorAuto`)', () { + final controles = controlesEcualizadorPersonalizados( + disponible: true, + activo: true, + l10n: l10n, + ); - expect(controles, hasLength(2)); - expect(controles.every((c) => c.action == MediaAction.custom), isTrue); - }, - ); + expect(controles, hasLength(1)); + expect(controles.single.action, MediaAction.custom); + expect(controles.single.customAction?.name, accionEqToggle); + }); test('toggle label reflects ON -> shows "disable" action', () { final controles = controlesEcualizadorPersonalizados( disponible: true, activo: true, - presetActual: PresetEcualizador.flat, l10n: l10n, ); final toggle = controles.firstWhere( @@ -135,7 +124,6 @@ void main() { final controles = controlesEcualizadorPersonalizados( disponible: true, activo: false, - presetActual: PresetEcualizador.flat, l10n: l10n, ); final toggle = controles.firstWhere( @@ -145,45 +133,68 @@ void main() { expect(toggle.label, l10n.eqCustomActionEnableLabel); }); - test('preset-cycle label shows the CURRENT preset localized name', () { - final controles = controlesEcualizadorPersonalizados( + test('toggle icon reflects EQ state: ON uses ic_auto_eq_on, OFF uses ' + 'ic_auto_eq_off -- a car head unit foregrounds the icon over the ' + 'label, so the icon itself must change, not just the text', () { + final activado = controlesEcualizadorPersonalizados( disponible: true, activo: true, - presetActual: PresetEcualizador.jazz, l10n: l10n, - ); - final ciclo = controles.firstWhere( - (c) => c.customAction?.name == accionEqPresetSiguiente, - ); - - expect( - ciclo.label, - l10n.eqCustomActionPresetLabel(l10n.equalizerPresetJazz), - ); - }); - - test('both actions reuse the bundled notification drawable (zero new ' - 'native assets)', () { - final controles = controlesEcualizadorPersonalizados( + ).firstWhere((c) => c.customAction?.name == accionEqToggle); + final desactivado = controlesEcualizadorPersonalizados( disponible: true, - activo: true, - presetActual: PresetEcualizador.flat, + activo: false, l10n: l10n, - ); + ).firstWhere((c) => c.customAction?.name == accionEqToggle); - expect( - controles.every((c) => c.androidIcon == 'drawable/ic_stat_pluriwave'), - isTrue, - ); + expect(activado.androidIcon, 'drawable/ic_auto_eq_on'); + expect(desactivado.androidIcon, 'drawable/ic_auto_eq_off'); }); }); - group('action name constants (item 4 — collision-free with car-tree ids)', () { - test('names are non-empty, distinct, and do not collide with any ' - 'existing browse-tree media-id prefix', () { - expect(accionEqToggle, isNotEmpty); - expect(accionEqPresetSiguiente, isNotEmpty); - expect(accionEqToggle, isNot(equals(accionEqPresetSiguiente))); + group( + 'action name constants (item 4 -- collision-free with car-tree ids)', + () { + test('accionEqToggle is non-empty and does not collide with any ' + 'existing browse-tree media-id prefix -- accionEqPresetSiguiente ' + '(decision `auto/ecualizador-diseno`: removed, superseded by the ' + '"Ecualizador" browsable folder) no longer exists as a symbol at ' + 'all, which this file compiling proves on its own', () { + expect(accionEqToggle, isNotEmpty); + }); + }, + ); + + group('equalizer drawable assets on disk (on-device feedback follow-up: the ' + 'two custom actions used to share one drawable and were visually ' + 'indistinguishable)', () { + test('ic_auto_eq_on and ic_auto_eq_off exist under ' + 'android/app/src/main/res/drawable/ -- a missing drawable is not a ' + 'build error, it silently renders blank/default on the head unit, ' + 'so this is the only safety net that would have caught the ' + 'original duplication. ic_auto_eq_preset is deliberately NOT ' + 'checked here anymore -- decision `auto/ecualizador-diseno` ' + 'removes the preset-cycling action and its drawable', () { + for (final nombre in ['ic_auto_eq_on', 'ic_auto_eq_off']) { + final archivo = File('android/app/src/main/res/drawable/$nombre.xml'); + expect( + archivo.existsSync(), + isTrue, + reason: + '$nombre.xml must exist under ' + 'android/app/src/main/res/drawable/', + ); + } + expect( + File( + 'android/app/src/main/res/drawable/ic_auto_eq_preset.xml', + ).existsSync(), + isFalse, + reason: + 'ic_auto_eq_preset.xml must be REMOVED -- decision ' + '`auto/ecualizador-diseno` retires the preset-cycling ' + 'custom action it belonged to', + ); }); }); } diff --git a/test/servicios/servicio_audio_eq_folder_test.dart b/test/servicios/servicio_audio_eq_folder_test.dart new file mode 100644 index 0000000..ed9f9f1 --- /dev/null +++ b/test/servicios/servicio_audio_eq_folder_test.dart @@ -0,0 +1,123 @@ +import 'dart:ui' show Locale; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/servicios/navegacion_auto.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// Decision `auto/ecualizador-diseno` -- the "Ecualizador" browsable +/// folder's item-building (`itemsEcualizadorAuto`), the pure, +/// `AppLocalizations`-dependent half of the folder feature. Lives in a +/// dedicated file, separate from `servicio_audio_eq_custom_actions_test.dart` +/// (which covers the now-playing screen's on/off toggle) because this is a +/// different car UI surface: a browsable folder, not a custom action. +void main() { + final l10n = lookupAppLocalizations(const Locale('es')); + + group('itemsEcualizadorAuto (decision `auto/ecualizador-diseno`)', () { + test('devuelve exactamente 7 items: Desactivar primero, luego los 6 ' + 'presets de fábrica, todos playable', () { + final items = itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + ); + + expect(items, hasLength(7)); + expect(items.first.id, ConstructorArbolAuto.idDesactivarEq); + for (final item in items) { + expect(item.playable, isTrue); + } + }); + + test('los 6 presets aparecen en el mismo orden que ' + 'PresetEcualizador.presets, cada uno con su id eq_preset:', () { + final items = itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + ); + final builder = ConstructorArbolAuto(); + + final idsPresets = items.skip(1).map((i) => i.id).toList(); + final idsEsperados = + PresetEcualizador.presets + .map((p) => builder.idPresetEq(p.nombre)) + .toList(); + + expect(idsPresets, idsEsperados); + }); + + test('los nombres de preset están localizados vía nombrePresetVisible, ' + 'no crudos', () { + final items = itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + ); + final builder = ConstructorArbolAuto(); + + final rock = items.firstWhere( + (i) => i.id == builder.idPresetEq(PresetEcualizador.rock.nombre), + ); + + expect(rock.title, contains(nombrePresetVisible(l10n, 'Rock'))); + }); + + test('con el ecualizador ACTIVO, el preset actual queda marcado y ' + 'Desactivar NO', () { + final items = itemsEcualizadorAuto( + activo: true, + presetActual: PresetEcualizador.jazz, + l10n: l10n, + ); + final builder = ConstructorArbolAuto(); + + final desactivar = items.firstWhere( + (i) => i.id == ConstructorArbolAuto.idDesactivarEq, + ); + final jazz = items.firstWhere( + (i) => i.id == builder.idPresetEq(PresetEcualizador.jazz.nombre), + ); + final marcados = items.where((i) => i.title.contains('✓')).toList(); + + expect(desactivar.title, isNot(contains('✓'))); + expect(jazz.title, contains('✓')); + expect(marcados, hasLength(1)); + expect(marcados.single.id, jazz.id); + }); + + test('con el ecualizador DESACTIVADO, Desactivar queda marcado y NINGÚN ' + 'preset lo está', () { + final items = itemsEcualizadorAuto( + activo: false, + presetActual: PresetEcualizador.rock, + l10n: l10n, + ); + + final desactivar = items.firstWhere( + (i) => i.id == ConstructorArbolAuto.idDesactivarEq, + ); + final marcados = items.where((i) => i.title.contains('✓')).toList(); + + expect(desactivar.title, contains('✓')); + expect(marcados, hasLength(1)); + expect(marcados.single.id, ConstructorArbolAuto.idDesactivarEq); + }); + + test('el label de Desactivar usa l10n.autoEqDisableOption', () { + final items = itemsEcualizadorAuto( + activo: false, + presetActual: PresetEcualizador.flat, + l10n: l10n, + ); + + final desactivar = items.firstWhere( + (i) => i.id == ConstructorArbolAuto.idDesactivarEq, + ); + + expect(desactivar.title, contains(l10n.autoEqDisableOption)); + }); + }); +}