From 20706fb9a4ec646816b44a10842aa8f58ea0fd75 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 16 Sep 2026 11:01:02 +0200 Subject: music: play, play next, add to queue The player's queue could only be replaced: every onPlayQueue reset tracks/order/pos together. It becomes one reducer (queue-ops.js) with an `op`, because two appends batched into one tick cannot both read the track count out of separate useStates. A shared pop-up menu (menu.js) carries the three verbs, on right-click and on a dots button. A track row is now a div holding two buttons: a button cannot contain a button. Found by the browser probe: both music wrappers took two arguments and forwarded two, so every "add to queue" arrived as a plain play. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/tests/test_music_queue.py | 115 +++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_music_queue.py (limited to 'packages/meshbay-hub/tests/test_music_queue.py') diff --git a/packages/meshbay-hub/tests/test_music_queue.py b/packages/meshbay-hub/tests/test_music_queue.py new file mode 100644 index 0000000..a53047d --- /dev/null +++ b/packages/meshbay-hub/tests/test_music_queue.py @@ -0,0 +1,115 @@ +""" +Play, play next, add to queue — pressed in a real browser. + +`test_queue_ops.py` executes the reducer directly, which is where the index +arithmetic is proved. It cannot reach any of this: whether a right-click opens +a menu at all, whether the dots button still works now that a track row is a +div holding two buttons rather than one big one, and whether `op` survives the +trip from the menu through the view, the page's wrapper and the shell. + +That last one is not hypothetical. Both wrappers took `(tracks, startIndex)` +and forwarded two arguments, so every "add to queue" in a group arrived at the +player as a plain play and silently replaced the queue. Nothing about that +reads as wrong at either end; this probe is what found it. + +The probe renders the shipped `GroupPage` and `MusicPlayerBar`, presses the +real controls, and reads the queue out of the player's own panel. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "music_queue_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "queue-ops.js").exists(), + reason="Chrome or the SPA sources are not available") + +ALBUM = {n: [f"A{n}-t1", f"A{n}-t2", f"A{n}-t3"] for n in (1, 2, 3, 4)} + + +@pytest.fixture(scope="module") +def steps(): + proc = subprocess.run(["python3", str(HARNESS)], + capture_output=True, text=True, timeout=300) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, out + assert not out.get("logs"), f"the page logged: {out['logs']}" + return {s["step"]: s for s in out["steps"]} + + +def test_playing_a_track_queues_its_album(steps): + """The ordinary path, through a row that is no longer one single button.""" + s = steps["play a track"] + assert s["play"] == ALBUM[1] + assert s["playing"] == 1 + assert s["nowPlaying"] == "A1-t2" + + +def test_right_click_opens_the_three_queue_verbs(steps): + """Three items, in the order the menu promises. Their labels are whatever + the browser's locale renders, so this counts them rather than reading them + — the point is that the menu opened and is not empty.""" + assert len(steps["append album 2"]["menu"]) == 3 + + +def test_add_to_queue_appends_and_leaves_the_playhead_alone(steps): + """The bug this file exists for: this used to replace the queue.""" + s = steps["append album 2"] + assert s["play"] == ALBUM[1] + ALBUM[2] + assert s["nowPlaying"] == "A1-t2", "appending moved what was playing" + + +def test_play_next_lands_after_the_playing_track(steps): + """Reached through the dots button rather than a right-click, so both + affordances are exercised.""" + s = steps["play next album 3"] + assert s["play"] == ["A1-t1", "A1-t2"] + ALBUM[3] + ["A1-t3"] + ALBUM[2] + assert s["nowPlaying"] == "A1-t2" + + +def test_shuffling_keeps_everything_that_was_queued(steps): + on = steps["shuffle on"] + assert sorted(on["play"]) == sorted(ALBUM[1] + ALBUM[2] + ALBUM[3]) + assert on["playing"] == 0 and on["nowPlaying"] == "A1-t2", ( + "shuffle moved the track that was already playing") + + +def test_shuffling_off_returns_to_the_order_tracks_were_added_in(steps): + """Not to the play order "play next" built: unshuffled *is* the order the + tracks arrived in, and that is what it has always meant. A track inserted + next while shuffled keeps its place only while shuffle is on.""" + s = steps["shuffle off"] + assert s["play"] == ALBUM[1] + ALBUM[2] + ALBUM[3] + assert s["nowPlaying"] == "A1-t2" + + +def test_play_all_replaces_everything(steps): + s = steps["replace with album 4"] + assert s["play"] == ALBUM[4] + assert s["playing"] == 0 + + +# ── the wrappers, read rather than driven ──────────────────────────────────── +# +# The probe proves the chain works for the group page. Search mounts the same +# view through a wrapper of its own, and there is no cheap way to drive that +# page's index fetch here — so this reads the one thing that broke. + +@pytest.mark.parametrize("name", ["group-page.js", "search-page.js"]) +def test_the_music_wrapper_names_the_op_it_forwards(name): + """A wrapper that takes two arguments forwards two, and the third is lost + without a word. Both of these did exactly that.""" + src = (STATIC / name).read_text() + marker = ("const onPlayQueue = useCallback((tracks, startIndex, op)" + if name == "group-page.js" + else "const handleMusicPlay = useCallback((tracks, startIndex, op)") + assert marker in src, ( + f"{name}'s music wrapper no longer names `op`; every 'add to queue' " + f"and 'play next' reaching it becomes a plain play") -- cgit v1.2.3