""" 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")