feat(auto): page local-music folders instead of truncating at 50 [size:exception]

Folders over the 50-item cap now show a "Mas..." item that reveals
the next page on tap, instead of silently dropping the rest. Paging
slices the cheap raw list before building any MediaItem, so items
beyond the requested page are never resolved (art, title) -- proven
by a call-count test. Also swaps the raw SAF content:// URI shown in
settings for a parsed, human-readable folder name with a localized
fallback across all 13 locales.

servicio_audio.dart is untouched; this stays entirely within the
local-music tree/dispatch layer.
This commit is contained in:
2026-07-19 22:14:05 +02:00
parent 977cbcd8cc
commit 725169cd31
25 changed files with 1601 additions and 40 deletions
+439 -14
View File
@@ -370,26 +370,365 @@ void main() {
);
});
group('paginaDe', () {
test('slicea al medio de la lista', () {
final items = List.generate(10, (i) => i);
expect(paginaDe(items, pagina: 1, tamano: 3), [3, 4, 5]);
});
test('lista vacía devuelve lista vacía', () {
expect(paginaDe(<int>[], pagina: 0, tamano: 5), isEmpty);
});
test('página más allá del rango devuelve lista vacía, sin lanzar', () {
final items = List.generate(5, (i) => i);
expect(paginaDe(items, pagina: 3, tamano: 5), isEmpty);
});
test('última página parcial devuelve solo los elementos restantes', () {
final items = List.generate(7, (i) => i);
expect(paginaDe(items, pagina: 1, tamano: 5), [5, 6]);
});
});
group('hayPaginaSiguiente', () {
test(
'boundary exacto: total == (pagina+1)*tamano es false, mayor es true',
() {
expect(hayPaginaSiguiente(50, pagina: 0, tamano: 50), isFalse);
expect(hayPaginaSiguiente(51, pagina: 0, tamano: 50), isTrue);
expect(hayPaginaSiguiente(100, pagina: 1, tamano: 50), isFalse);
expect(hayPaginaSiguiente(101, pagina: 1, tamano: 50), isTrue);
},
);
});
group(
'esCarpetaLocalPaginadaMediaId / paginaCarpetaLocalDesde',
() {
test(
'reconoce el prefijo carpeta_local_pag: y no colisiona con '
'emisora:/grupo:/eq_preset:/carpeta_local:/pista: ni con los ids de '
'carpeta fijos',
() {
final builder = ConstructorArbolAuto();
expect(
builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:doc1'),
isTrue,
);
expect(
builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:'),
isTrue,
);
final otros = <String>[
'emisora:x',
'grupo:g1',
'eq_preset:Rock',
'carpeta_local:doc1',
'pista:doc1',
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idEcualizador,
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();
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();
expect(
builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:'),
('', 1),
);
},
);
},
);
group('ConstructorArbolAuto.itemsLocales', () {
test('ordena alfabéticamente por nombre y capea a 50', () {
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',
() {
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) {
llamadas++;
return MediaItem(id: 'pista:${nodo.documentId}', title: nodo.nombre);
}
llamadas = 0;
builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 0,
construirItem: espia,
);
expect(llamadas, 50);
llamadas = 0;
builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 1,
construirItem: espia,
);
expect(llamadas, 50);
llamadas = 0;
builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 3,
construirItem: espia,
);
expect(llamadas, 50); // 200 - 150 = 50 restantes
llamadas = 0;
builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 4,
construirItem: espia,
);
expect(llamadas, 0); // más allá del rango, no queda nada
},
);
test(
'exactamente 50 items: no aparece "Más…", nada se pierde',
() {
final nodos = List.generate(
50,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
)..shuffle();
final items = ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'x',
);
expect(items, hasLength(50));
expect(items.first.title, 'cancion_00');
expect(items.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…"',
() {
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 = builder.itemsLocales(nodos, documentIdPadre: 'x');
expect(pagina0, hasLength(51));
expect(pagina0.first.title, 'cancion_00');
expect(pagina0[49].title, 'cancion_49');
final mas0 = pagina0.last;
expect(mas0.title, 'Más…');
expect(mas0.playable, isFalse);
expect(mas0.artUri, isNull);
final pagina1 = builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 1,
);
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',
() {
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 = <String>{};
var pagina = 0;
var seguir = true;
var paginasVisitadas = 0;
while (seguir) {
final items = builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: pagina,
);
paginasVisitadas++;
final tieneMas = items.isNotEmpty && items.last.title == 'Más…';
final hojas = tieneMas ? items.sublist(0, items.length - 1) : items;
for (final item in hojas) {
vistos.add(item.id);
}
seguir = tieneMas;
pagina++;
}
expect(paginasVisitadas, greaterThanOrEqualTo(3));
expect(vistos, hasLength(120));
},
);
test(
'el item "Más…" tiene forma round-trip: id '
'carpeta_local_pag:<n+1>:<docId>, playable false, sin artUri',
() {
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 = builder.itemsLocales(
nodos,
documentIdPadre: 'sub1',
pagina: 0,
);
final mas = items.last;
expect(mas.id, 'carpeta_local_pag:1:sub1');
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),
);
},
);
test('paginación en la raíz (documentIdPadre vacío) también funciona', () {
final nodos = List.generate(
60,
51,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
)..shuffle();
);
final builder = ConstructorArbolAuto();
final items = ConstructorArbolAuto().itemsLocales(nodos);
final items = builder.itemsLocales(nodos, documentIdPadre: '');
final mas = items.last;
expect(items, hasLength(50));
expect(items.first.title, 'cancion_00');
expect(items[1].title, 'cancion_01');
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',
() {
final nodos = List.generate(
10,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_$i.mp3',
esDirectorio: false,
),
);
final items = ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 5,
);
expect(items, isEmpty);
},
);
test('subcarpeta vacía devuelve lista vacía, no un error', () {
expect(ConstructorArbolAuto().itemsLocales(const []), isEmpty);
expect(
ConstructorArbolAuto().itemsLocales(const [], documentIdPadre: 'x'),
isEmpty,
);
});
test(
@@ -402,7 +741,9 @@ void main() {
esDirectorio: true,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.id, 'carpeta_local:doc-carpeta');
expect(item.playable, isFalse);
@@ -420,7 +761,9 @@ void main() {
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.id, 'pista:doc-pista');
expect(item.playable, isTrue);
@@ -435,7 +778,9 @@ void main() {
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.title, 'CancionSinExtension');
});
@@ -450,7 +795,9 @@ void main() {
nombre: nombre,
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.title, isNotEmpty);
}
},
@@ -466,7 +813,9 @@ void main() {
nombre: '.mp3',
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.title, '.mp3');
},
);
@@ -481,7 +830,9 @@ void main() {
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.artUri.toString(), artUriLocal('doc-arte'));
expect(
@@ -589,6 +940,80 @@ void main() {
expect(resultado, isEmpty);
},
);
test(
'un id carpeta_local_pag:<n>:<docId> 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,
);
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();
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,
);
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'),
);
final resultado = await hijosMusicaLocal(
'carpeta_local_pag:1:sub1',
fuente: fuente,
);
expect(resultado, isNotNull);
expect(resultado, isEmpty);
},
);
});
group('reproducirPistaLocal', () {