Phase 1c.2.1 splits each wishlist run across multiple
``download_batches`` rows (per-album bundle dispatch). The
download-missing modal opens against the original batch_id
allocated by ``start_manual_wishlist_download_batch`` /
``process_wishlist_automatically``. Pre-fix that batch_id was
just one sibling among N, so the modal went stale as soon as the
primary sub-batch finished — subsequent albums downloaded fine
but no live status reached the UI.
Fix: backend merges every sibling sub-batch's tasks +
analysis_results into the response keyed under the originally-
requested batch_id. Modal sees one unified view of the whole run
without knowing about the split. Frontend untouched.
Architecture (Kettui standards):
- ``core/downloads/wishlist_aggregator.py`` — pure
``merge_wishlist_run_status(primary, siblings)`` helper.
No IO, no runtime state, no globals. Lifted out of
``status.py`` so the merge contract can be pinned via unit
tests without standing up the live ``download_batches`` /
``download_tasks`` state.
- ``core/downloads/status.py``'s ``build_batched_status`` now
pre-indexes ``download_batches`` by ``wishlist_run_id`` inside
the existing ``tasks_lock`` snapshot, then runs the merge
helper whenever a requested batch has a sibling.
Merge rules pinned by 12 tests:
- ``track_index`` re-indexed globally 0..N-1 across the merged
``analysis_results`` so the modal's ``data-track-index`` DOM
keys don't collide between siblings. Tasks' ``track_index``
follows the same remap so the analysis-results ↔ tasks
cross-reference stays intact.
- ``task_id`` is uuid per task — no collision concern.
- Phase: error is sticky; otherwise the LEAST-complete
pre-terminal phase wins (analysis < album_downloading <
downloading). All-complete returns ``complete``; mixed
complete + active returns ``downloading`` so the modal stays
alive until every sibling lands.
- ``album_bundle``: picks whichever sibling currently has an
active bundle download (state in
``{searching, downloading, downloading_release, staging}``).
Falls back to the first non-empty bundle so a completed run
still shows a progress bar.
- ``analysis_progress`` summed across siblings.
- ``active_count`` summed; ``max_concurrent`` keeps primary's
value as the representative.
- ``playlist_id`` + ``playlist_name`` preserved from the primary
(the row the modal originally opened against).
Legacy single-batch wishlist runs (no ``wishlist_run_id`` on the
batch) skip the merge entirely — passthrough. Back-compat by
absence.
1108 tests across downloads + wishlist + automation + imports +
playlist-sources + lb-series suites green. 12 new aggregator
tests pin the merge contract.
Closes the open UX gap from the Phase 1c.2.1 ship — modal now
tracks every sibling sub-batch's progress for the full duration
of the wishlist run.
168 lines
6.3 KiB
Python
168 lines
6.3 KiB
Python
"""Unit tests for ``core/downloads/wishlist_aggregator.merge_wishlist_run_status``.
|
|
|
|
Pins the merge contract the wishlist-modal status path depends on
|
|
(Phase 1c.2.1 follow-up): when one logical wishlist run is split
|
|
across N sub-batches, the frontend modal polls the original
|
|
batch_id and expects a unified view that covers every sibling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from core.downloads.wishlist_aggregator import merge_wishlist_run_status
|
|
|
|
|
|
def _status(phase, **kwargs):
|
|
"""Build a minimal per-batch status dict shaped like
|
|
``build_batch_status_data``'s output."""
|
|
base = {
|
|
'phase': phase,
|
|
'playlist_id': 'wishlist',
|
|
'playlist_name': 'Wishlist',
|
|
'active_count': 0,
|
|
'max_concurrent': 3,
|
|
}
|
|
base.update(kwargs)
|
|
return base
|
|
|
|
|
|
def test_empty_siblings_returns_primary_unchanged():
|
|
primary = _status('downloading', tasks=[{'task_id': 't1', 'track_index': 0}])
|
|
out = merge_wishlist_run_status(primary, [])
|
|
assert out is primary
|
|
|
|
|
|
def test_two_siblings_merge_tasks_with_reindexed_track_index():
|
|
"""Both siblings locally start at track_index 0 — after merge,
|
|
indices are globally unique 0..N-1."""
|
|
primary = _status(
|
|
'downloading',
|
|
analysis_results=[
|
|
{'track_index': 0, 'track': {'name': 'A1'}, 'found': False, 'confidence': 0.0},
|
|
{'track_index': 1, 'track': {'name': 'A2'}, 'found': False, 'confidence': 0.0},
|
|
],
|
|
tasks=[
|
|
{'task_id': 'task-a1', 'track_index': 0, 'status': 'downloading'},
|
|
{'task_id': 'task-a2', 'track_index': 1, 'status': 'downloading'},
|
|
],
|
|
)
|
|
sibling = _status(
|
|
'downloading',
|
|
analysis_results=[
|
|
{'track_index': 0, 'track': {'name': 'B1'}, 'found': False, 'confidence': 0.0},
|
|
],
|
|
tasks=[
|
|
{'task_id': 'task-b1', 'track_index': 0, 'status': 'searching'},
|
|
],
|
|
)
|
|
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
|
|
# Three globally-unique track indices.
|
|
assert [r['track_index'] for r in merged['analysis_results']] == [0, 1, 2]
|
|
# Each task's track_index re-indexed to match its analysis_result.
|
|
indices_by_task = {t['task_id']: t['track_index'] for t in merged['tasks']}
|
|
assert indices_by_task == {'task-a1': 0, 'task-a2': 1, 'task-b1': 2}
|
|
# Tasks sorted by their new track_index.
|
|
assert [t['task_id'] for t in merged['tasks']] == ['task-a1', 'task-a2', 'task-b1']
|
|
|
|
|
|
def test_phase_aggregation_least_complete_pre_terminal_wins():
|
|
"""analysis + downloading + complete → analysis."""
|
|
primary = _status('complete')
|
|
sibling1 = _status('downloading')
|
|
sibling2 = _status('analysis')
|
|
merged = merge_wishlist_run_status(primary, [sibling1, sibling2])
|
|
assert merged['phase'] == 'analysis'
|
|
|
|
|
|
def test_phase_aggregation_album_downloading_wins_over_downloading():
|
|
primary = _status('downloading')
|
|
sibling = _status('album_downloading')
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
assert merged['phase'] == 'album_downloading'
|
|
|
|
|
|
def test_phase_aggregation_all_complete_returns_complete():
|
|
primary = _status('complete')
|
|
sibling1 = _status('complete')
|
|
merged = merge_wishlist_run_status(primary, [sibling1])
|
|
assert merged['phase'] == 'complete'
|
|
|
|
|
|
def test_phase_aggregation_mixed_complete_and_other_returns_downloading():
|
|
"""A finished sibling alongside a still-downloading sibling
|
|
surfaces 'downloading' (the run isn't done)."""
|
|
primary = _status('complete')
|
|
sibling = _status('downloading')
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
assert merged['phase'] == 'downloading'
|
|
|
|
|
|
def test_phase_aggregation_error_is_sticky():
|
|
"""If any sibling errored, the merged phase is 'error' even
|
|
if other siblings are still running. Modal should show the
|
|
failure so the user notices."""
|
|
primary = _status('downloading')
|
|
sibling = _status('error')
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
assert merged['phase'] == 'error'
|
|
|
|
|
|
def test_analysis_progress_summed_across_siblings():
|
|
primary = _status(
|
|
'analysis',
|
|
analysis_progress={'total': 10, 'processed': 7},
|
|
)
|
|
sibling = _status(
|
|
'analysis',
|
|
analysis_progress={'total': 5, 'processed': 2},
|
|
)
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
assert merged['analysis_progress'] == {'total': 15, 'processed': 9}
|
|
|
|
|
|
def test_album_bundle_picks_active_sibling_over_idle():
|
|
"""Primary is past its bundle stage (state='staged');
|
|
sibling is currently downloading_release. Merge surfaces the
|
|
active sibling's bundle so the progress bar stays useful."""
|
|
primary = _status(
|
|
'downloading',
|
|
album_bundle={'state': 'staged', 'progress': 100, 'release': 'PRISM (Deluxe)'},
|
|
)
|
|
sibling = _status(
|
|
'album_downloading',
|
|
album_bundle={'state': 'downloading_release', 'progress': 42, 'release': '1432'},
|
|
)
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
assert merged['album_bundle']['release'] == '1432'
|
|
assert merged['album_bundle']['progress'] == 42
|
|
|
|
|
|
def test_album_bundle_falls_back_when_no_active_sibling():
|
|
primary = _status(
|
|
'complete',
|
|
album_bundle={'state': 'staged', 'progress': 100, 'release': 'PRISM (Deluxe)'},
|
|
)
|
|
sibling = _status(
|
|
'complete',
|
|
album_bundle={'state': 'staged', 'progress': 100, 'release': '1432'},
|
|
)
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
# Falls back to primary's bundle (first non-empty).
|
|
assert merged['album_bundle']['release'] == 'PRISM (Deluxe)'
|
|
|
|
|
|
def test_active_count_summed_across_siblings():
|
|
primary = _status('downloading', active_count=2)
|
|
sibling = _status('downloading', active_count=1)
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
assert merged['active_count'] == 3
|
|
|
|
|
|
def test_primary_playlist_id_preserved():
|
|
primary = _status('downloading', playlist_id='wishlist', playlist_name='Wishlist (Auto)')
|
|
sibling = _status('downloading', playlist_id='wishlist', playlist_name='Wishlist (Album: 1432)')
|
|
merged = merge_wishlist_run_status(primary, [sibling])
|
|
# Primary's playlist_name + playlist_id propagate (it's the row the modal opened against).
|
|
assert merged['playlist_id'] == 'wishlist'
|
|
assert merged['playlist_name'] == 'Wishlist (Auto)'
|