aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_music_queue.py
blob: a53047daa67a65b059e9f0f4fb50249da2d022c0 (plain) (blame)
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")