aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_music_queue.py
blob: 5bf9e97ed62e6708787f7f2dc9918429bc93ab6c (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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
"""
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")