feat(auto): play a local-music folder's subfolders recursively too
totalPistas counted only DIRECT audio children, so "Reproducir carpeta"/ "Aleatorio" were hidden for a folder that contains only subfolders, and playing a folder queued only its direct tracks. Add a bounded recursive walk (pistasRecursivas) that collects every track beneath a folder, depth-first, sorted by name at each level. Bounded on two independent axes to keep a single tap's native SAF round-trips and in-memory list size predictable on a deep or wide library: - depth: 4 levels below the tapped folder (profundidadMaximaRecursivaLocal) - count: 500 tracks total (limitePistasRecursivasLocal) The folder-play/shuffle actions are now offered whenever the recursive count is > 0, and "Reproducir carpeta"/"Aleatorio" queue everything found, not just direct children.
This commit is contained in:
@@ -447,6 +447,12 @@ class ConstructorArbolAuto {
|
|||||||
int tamano = _maxItemsCarpetaLocal,
|
int tamano = _maxItemsCarpetaLocal,
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
|
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
|
||||||
|
// Item 2 (recursive folder play): optional so every pre-existing call
|
||||||
|
// site/test that has no need for the recursive gate keeps working
|
||||||
|
// unchanged. Only used on page 0, and only when [nodos] has zero
|
||||||
|
// DIRECT tracks (a direct track already makes the gate cheaply true
|
||||||
|
// without it) — see the `hayContenidoReproducible` computation below.
|
||||||
|
FuenteMusicaLocalAuto? fuente,
|
||||||
}) async {
|
}) async {
|
||||||
final construir = construirItem ?? _itemLocal;
|
final construir = construirItem ?? _itemLocal;
|
||||||
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
|
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
|
||||||
@@ -462,14 +468,26 @@ class ConstructorArbolAuto {
|
|||||||
}
|
}
|
||||||
if (pagina == 0) {
|
if (pagina == 0) {
|
||||||
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
|
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
|
||||||
|
// Item 2: a folder plays everything beneath it, recursively -- so
|
||||||
|
// the play actions must be offered whenever the RECURSIVE count is
|
||||||
|
// > 0, not just the direct count. `totalPistas > 0` short-circuits
|
||||||
|
// the bounded recursive walk entirely for the common case (a direct
|
||||||
|
// track already answers the question); only a folder with ZERO
|
||||||
|
// direct tracks but at least one subfolder pays the recursive-check
|
||||||
|
// cost, and only up to [profundidadMaximaRecursivaLocal] levels.
|
||||||
|
final hayContenidoReproducible =
|
||||||
|
totalPistas > 0 ||
|
||||||
|
(fuente != null &&
|
||||||
|
await _haySubcarpetaConPistas(nodos, fuente: fuente));
|
||||||
final prepend = <MediaItem>[
|
final prepend = <MediaItem>[
|
||||||
// Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE
|
// Folder-play actions (Design ADR-5, Phase 3; recursive gate item
|
||||||
// the sort/bucket nav entries, guarded the same shape as
|
// 2): prepended BEFORE the sort/bucket nav entries, present iff
|
||||||
// ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has
|
// the folder has at least one playable track anywhere beneath it
|
||||||
// at least one direct audio child, absent for a folder with only
|
// (direct or nested), absent for a folder that is genuinely empty
|
||||||
// subfolders (Spec "Folder has no tracks").
|
// even recursively (Spec "Folder has no tracks").
|
||||||
if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre),
|
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
|
||||||
if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre),
|
if (hayContenidoReproducible)
|
||||||
|
_itemReproducirAleatorio(documentIdPadre),
|
||||||
if (ofreceOrdenCalidad(totalPistas))
|
if (ofreceOrdenCalidad(totalPistas))
|
||||||
_itemModoOrdenCalidad(documentIdPadre),
|
_itemModoOrdenCalidad(documentIdPadre),
|
||||||
if (ofreceBuckets(totalPistas))
|
if (ofreceBuckets(totalPistas))
|
||||||
@@ -481,6 +499,32 @@ class ConstructorArbolAuto {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether at least one subfolder within [nodos] recursively contains a
|
||||||
|
/// playable track (Design "recursive folder play, gate", item 2): called
|
||||||
|
/// ONLY when the folder has zero DIRECT tracks (the caller already
|
||||||
|
/// checked that cheaply) — descends into each direct subfolder via
|
||||||
|
/// [pistasRecursivas] with `limite: 1`, stopping at the very first
|
||||||
|
/// match so a folder with an early hit costs as little as possible.
|
||||||
|
/// [nodos] is assumed already resolved by the caller (its own
|
||||||
|
/// `fuente.hijos(...)` result), so this folder's own children are never
|
||||||
|
/// re-fetched.
|
||||||
|
Future<bool> _haySubcarpetaConPistas(
|
||||||
|
List<NodoLocal> nodos, {
|
||||||
|
required FuenteMusicaLocalAuto fuente,
|
||||||
|
}) async {
|
||||||
|
for (final nodo in nodos) {
|
||||||
|
if (!nodo.esDirectorio) continue;
|
||||||
|
final encontradas = await pistasRecursivas(
|
||||||
|
nodo.documentId,
|
||||||
|
fuente: fuente,
|
||||||
|
profundidadMaxima: profundidadMaximaRecursivaLocal - 1,
|
||||||
|
limite: 1,
|
||||||
|
);
|
||||||
|
if (encontradas.isNotEmpty) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the "Ordenar por calidad" mode entry should be offered for a
|
/// Whether the "Ordenar por calidad" mode entry should be offered for a
|
||||||
/// folder with [totalPistas] audio files (Design ADR-3): present for
|
/// folder with [totalPistas] audio files (Design ADR-3): present for
|
||||||
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
|
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
|
||||||
@@ -946,17 +990,115 @@ List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
|
|||||||
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
|
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
|
||||||
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
|
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
|
||||||
|
|
||||||
|
/// Maximum recursion depth for "play folder recursively" (Design "recursive
|
||||||
|
/// folder play, cost bound", item 2): SAF directory listing is a native
|
||||||
|
/// round-trip PER folder, so unbounded recursion could turn a single tap
|
||||||
|
/// into dozens of channel calls for a pathologically deep tree. 4 levels
|
||||||
|
/// below the tapped folder covers virtually every real music-library
|
||||||
|
/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while
|
||||||
|
/// keeping a worst-case tree's native-call count bounded. A subfolder
|
||||||
|
/// beyond this depth is simply never explored — its tracks are not
|
||||||
|
/// collected, exactly like content beyond the browse tree's own page cap
|
||||||
|
/// is never listed.
|
||||||
|
const profundidadMaximaRecursivaLocal = 4;
|
||||||
|
|
||||||
|
/// Maximum number of tracks collected by a recursive folder walk (Design
|
||||||
|
/// "recursive folder play, cost bound", item 2): a folder-play/shuffle
|
||||||
|
/// queue beyond a few hundred tracks has no practical benefit, and an
|
||||||
|
/// unbounded collection risks an extremely long queue AND an extremely
|
||||||
|
/// long recursive walk over a huge library. 500 is an order of magnitude
|
||||||
|
/// above the existing quality-sort cap
|
||||||
|
/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for
|
||||||
|
/// a "play everything" action, while still bounded.
|
||||||
|
const limitePistasRecursivasLocal = 500;
|
||||||
|
|
||||||
|
/// Recursively collects every audio-file [NodoLocal] reachable from
|
||||||
|
/// [documentId] (Design "recursive folder play", item 2): [documentId]'s
|
||||||
|
/// own direct audio children, plus — for every direct subfolder — that
|
||||||
|
/// subfolder's own recursive result. Walked depth-first, sorted by
|
||||||
|
/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/
|
||||||
|
/// shuffle play actions already used pre-recursion), so the collected
|
||||||
|
/// order is deterministic and reproducible under a fixed shuffle seed.
|
||||||
|
///
|
||||||
|
/// Bounded on two independent axes so a pathological tree (very deep, or
|
||||||
|
/// very wide-and-deep) can never turn a single tap into an unbounded
|
||||||
|
/// number of native SAF round-trips or an unbounded in-memory list:
|
||||||
|
/// - [profundidadMaxima] caps how many folder levels BELOW [documentId]
|
||||||
|
/// are ever descended into (`0` = only [documentId]'s own direct
|
||||||
|
/// children, no descent at all).
|
||||||
|
/// - [limite] caps the TOTAL number of tracks collected across the whole
|
||||||
|
/// walk; collection stops (mid-folder if needed) the instant this many
|
||||||
|
/// have been gathered.
|
||||||
|
///
|
||||||
|
/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked
|
||||||
|
/// permission, a race with the OS SAF layer) is swallowed for that
|
||||||
|
/// subfolder only — sibling folders already queued for traversal are
|
||||||
|
/// still visited — mirroring this file's existing no-throw contract
|
||||||
|
/// (Design "no-op on empty/unresolvable folder").
|
||||||
|
Future<List<NodoLocal>> pistasRecursivas(
|
||||||
|
String documentId, {
|
||||||
|
required FuenteMusicaLocalAuto fuente,
|
||||||
|
int profundidadMaxima = profundidadMaximaRecursivaLocal,
|
||||||
|
int limite = limitePistasRecursivasLocal,
|
||||||
|
}) async {
|
||||||
|
final resultado = <NodoLocal>[];
|
||||||
|
await _recolectarPistasRecursivas(
|
||||||
|
documentId,
|
||||||
|
fuente: fuente,
|
||||||
|
profundidadRestante: profundidadMaxima,
|
||||||
|
limite: limite,
|
||||||
|
resultado: resultado,
|
||||||
|
);
|
||||||
|
return resultado;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _recolectarPistasRecursivas(
|
||||||
|
String documentId, {
|
||||||
|
required FuenteMusicaLocalAuto fuente,
|
||||||
|
required int profundidadRestante,
|
||||||
|
required int limite,
|
||||||
|
required List<NodoLocal> resultado,
|
||||||
|
}) async {
|
||||||
|
if (resultado.length >= limite) return;
|
||||||
|
final List<NodoLocal> hijos;
|
||||||
|
try {
|
||||||
|
hijos = await fuente.hijos(documentId);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||||
|
for (final nodo in ordenados) {
|
||||||
|
if (resultado.length >= limite) return;
|
||||||
|
if (nodo.esDirectorio) {
|
||||||
|
if (profundidadRestante <= 0) continue;
|
||||||
|
await _recolectarPistasRecursivas(
|
||||||
|
nodo.documentId,
|
||||||
|
fuente: fuente,
|
||||||
|
profundidadRestante: profundidadRestante - 1,
|
||||||
|
limite: limite,
|
||||||
|
resultado: resultado,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resultado.add(nodo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
|
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
|
||||||
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the
|
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item
|
||||||
/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP —
|
/// 2): resolves whichever of the two action prefixes matches [id]
|
||||||
/// the prefix itself is authoritative), fetches [fuente]'s direct children
|
/// (ignoring [aleatorio] for the STRIP — the prefix itself is
|
||||||
/// for that folder, filters to audio files, orders them ([aleatorio] picks
|
/// authoritative), RECURSIVELY collects every track beneath that folder
|
||||||
/// shuffled vs name order), and hands the resulting list to [iniciarCola].
|
/// via [pistasRecursivas] (direct children AND every nested subfolder, up
|
||||||
|
/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs
|
||||||
|
/// the recursive walk's own name-sorted order), and hands the resulting
|
||||||
|
/// list to [iniciarCola].
|
||||||
///
|
///
|
||||||
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
|
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
|
||||||
/// prefix; [fuente.hijos] throws or returns only directories (an
|
/// prefix; the folder (or everything beneath it, within the recursion
|
||||||
/// unresolvable/empty folder — Design "no-op on empty/unresolvable
|
/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable
|
||||||
/// folder").
|
/// folder") — [pistasRecursivas] never throws, so this never propagates an
|
||||||
|
/// exception either.
|
||||||
Future<void> reproducirCarpetaLocal(
|
Future<void> reproducirCarpetaLocal(
|
||||||
String id, {
|
String id, {
|
||||||
required bool aleatorio,
|
required bool aleatorio,
|
||||||
@@ -974,16 +1116,10 @@ Future<void> reproducirCarpetaLocal(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<NodoLocal> nodos;
|
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
|
||||||
try {
|
|
||||||
nodos = await fuente.hijos(documentId);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final pistas = aleatorio
|
final pistas = aleatorio
|
||||||
? pistasEnOrdenAleatorio(nodos, rng ?? Random())
|
? mezclarFisherYates(recolectadas, rng ?? Random())
|
||||||
: pistasEnOrdenNombre(nodos);
|
: recolectadas;
|
||||||
if (pistas.isEmpty) return;
|
if (pistas.isEmpty) return;
|
||||||
|
|
||||||
await iniciarCola(pistas);
|
await iniciarCola(pistas);
|
||||||
@@ -1129,6 +1265,7 @@ Future<List<MediaItem>?> hijosMusicaLocal(
|
|||||||
documentIdPadre: documentId,
|
documentIdPadre: documentId,
|
||||||
pagina: pagina,
|
pagina: pagina,
|
||||||
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
||||||
|
fuente: fuente,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return const [];
|
return const [];
|
||||||
|
|||||||
@@ -2334,8 +2334,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'carpeta con solo subcarpetas (sin pistas de audio directas) es un '
|
'carpeta con solo subcarpetas (sin pistas de audio directas), y esa '
|
||||||
'no-op: iniciarCola nunca se llama',
|
'subcarpeta TAMBIÉN está vacía incluso recursivamente, es un no-op: '
|
||||||
|
'iniciarCola nunca se llama',
|
||||||
() async {
|
() async {
|
||||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||||
hijosPorDocId: {
|
hijosPorDocId: {
|
||||||
@@ -2361,6 +2362,43 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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<NodoLocal>? recibidas;
|
||||||
|
|
||||||
|
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']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar '
|
'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar '
|
||||||
'la excepción',
|
'la excepción',
|
||||||
@@ -3000,6 +3038,363 @@ void main() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Item 2: Android Auto recursive folder play ─────────────────────────
|
||||||
|
// totalPistas used to count only DIRECT audio children, so "Reproducir
|
||||||
|
// carpeta"/"Aleatorio" were hidden for a folder that contains only
|
||||||
|
// subfolders, and playing a folder queued only its direct tracks. Fix: a
|
||||||
|
// 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(
|
||||||
|
'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,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
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
|
/// Fake `metadatosDe` that always resolves to an empty map — used by every
|
||||||
@@ -3075,13 +3470,15 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
|||||||
Object? errorEnHijos,
|
Object? errorEnHijos,
|
||||||
Object? errorEnUriContenido,
|
Object? errorEnUriContenido,
|
||||||
Object? errorEnMetadatosDe,
|
Object? errorEnMetadatosDe,
|
||||||
|
Set<String>? idsConErrorEnHijos,
|
||||||
}) : _configurada = configurada,
|
}) : _configurada = configurada,
|
||||||
_hijosPorDocId = hijosPorDocId ?? const {},
|
_hijosPorDocId = hijosPorDocId ?? const {},
|
||||||
_uriPorDocId = uriPorDocId ?? const {},
|
_uriPorDocId = uriPorDocId ?? const {},
|
||||||
_metadatosPorDocId = metadatosPorDocId ?? const {},
|
_metadatosPorDocId = metadatosPorDocId ?? const {},
|
||||||
_errorEnHijos = errorEnHijos,
|
_errorEnHijos = errorEnHijos,
|
||||||
_errorEnUriContenido = errorEnUriContenido,
|
_errorEnUriContenido = errorEnUriContenido,
|
||||||
_errorEnMetadatosDe = errorEnMetadatosDe;
|
_errorEnMetadatosDe = errorEnMetadatosDe,
|
||||||
|
_idsConErrorEnHijos = idsConErrorEnHijos ?? const {};
|
||||||
|
|
||||||
final bool _configurada;
|
final bool _configurada;
|
||||||
final Map<String, List<NodoLocal>> _hijosPorDocId;
|
final Map<String, List<NodoLocal>> _hijosPorDocId;
|
||||||
@@ -3091,6 +3488,11 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
|||||||
final Object? _errorEnUriContenido;
|
final Object? _errorEnUriContenido;
|
||||||
final Object? _errorEnMetadatosDe;
|
final Object? _errorEnMetadatosDe;
|
||||||
|
|
||||||
|
/// Item 2 (recursive folder play): documentIds whose `hijos` call throws
|
||||||
|
/// -- used to prove a single unresolvable subfolder does not abort the
|
||||||
|
/// whole recursive walk, unlike [_errorEnHijos] (which fails EVERY call).
|
||||||
|
final Set<String> _idsConErrorEnHijos;
|
||||||
|
|
||||||
/// Records every `metadatosDe` call's documentIds list, in call order —
|
/// Records every `metadatosDe` call's documentIds list, in call order —
|
||||||
/// used by tests asserting the page-scoped resolution invariant at the
|
/// used by tests asserting the page-scoped resolution invariant at the
|
||||||
/// `hijosMusicaLocal` level.
|
/// `hijosMusicaLocal` level.
|
||||||
@@ -3102,6 +3504,9 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
|||||||
@override
|
@override
|
||||||
Future<List<NodoLocal>> hijos(String documentId) async {
|
Future<List<NodoLocal>> hijos(String documentId) async {
|
||||||
if (_errorEnHijos != null) throw _errorEnHijos;
|
if (_errorEnHijos != null) throw _errorEnHijos;
|
||||||
|
if (_idsConErrorEnHijos.contains(documentId)) {
|
||||||
|
throw Exception('permiso revocado para $documentId');
|
||||||
|
}
|
||||||
return _hijosPorDocId[documentId] ?? const [];
|
return _hijosPorDocId[documentId] ?? const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user