1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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")
|