""" 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_queue_verbs(steps): """The menu opened and carries the three queue verbs, plus "add to playlist" — which is why this counts four rather than three. Labels are whatever the browser's locale renders, so counting is what this can assert; `test_playlist_ui.py` is what checks the fourth one leads anywhere.""" assert len(steps["append album 2"]["menu"]) == 4 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 def test_an_unreachable_group_is_skipped_whole_rather_than_one_track_at_a_time(steps): """A regression playlists would otherwise introduce into correct code. `MAX_CONSECUTIVE_FAILURES` is 5 and was sized for a corrupt file between two good ones. A playlist whose next six tracks all come from one node that is off hits that bound and stops on the sixth, with an error, and the reader reads it as "the playlist is broken". A connection failure is a property of the *group*, not of each of its tracks in turn, so the group is marked down and all of its queued tracks go in one step. Six dead tracks here, one more than the bound: without the split this lands on the last of them instead of past them. """ assert steps["an unreachable group is skipped whole"]["nowPlaying"] == "live-2" # ── 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")