summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_queue_ops.py
blob: 64b5ca17ef9eceaf2a61b8e25abb59c2db05b34c (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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
"""
The music player's queue: replace, append, play-next, remove, reshuffle.

The queue was replaceable and nothing else — every `onPlayQueue` reset
`tracks`, `order` and `pos` together, which is all an album needs. Playlists
add "play next" and "add to queue", which do not replace, and the three
`useState`s they would have been built on cannot express an append correctly:
`setOrder` needs the length `setTracks` is about to produce and cannot see it,
so two enqueues batched into one tick both read the stale length and write
indices past the end of `tracks`. `queue-ops.js` is one reducer over one state
object, which is the only shape in which that is not a defect.

The whole module is executed here rather than a regex-extracted function of it:
it has no imports precisely so that it can be, and a copy of the reducer in a
test would keep agreeing with the original right up until one of them changed.

See docs/playlists.md §9.
"""

import json
import re
import shutil
import subprocess
from pathlib import Path

import pytest

STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
SRC = STATIC / "queue-ops.js"

pytestmark = pytest.mark.skipif(
    shutil.which("node") is None or not SRC.exists(),
    reason="node or the SPA sources are not available")

IMPORT = re.compile(r"^\s*import\b", re.M)
EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M)


@pytest.fixture(scope="module")
def module_source():
    text = SRC.read_text()
    assert not IMPORT.search(text), (
        "queue-ops.js has gained an import. It is executed standalone here, "
        "and the queue is untested from the moment it cannot be — keep the "
        "module free of imports, or this test needs a bundler")
    stripped, n = EXPORT.subn("", text)
    assert n == 1, (
        "queue-ops.js no longer ends in a single export statement — the test "
        "can no longer strip it to run the module")
    return stripped


def _run(tmp_path, module_source, body):
    script = tmp_path / "case.js"
    script.write_text(f"{module_source}\n{body}\n")
    out = subprocess.run(
        ["node", str(script)], capture_output=True, text=True, timeout=30)
    assert out.returncode == 0, out.stderr
    return json.loads(out.stdout)


def _tracks(*ids):
    return [{"id": i, "name": f"{i}.flac", "size": 1, "groupId": "g"} for i in ids]


def _reduce(tmp_path, module_source, actions, start=None):
    """Fold `actions` over the reducer, one after another, and report the end
    state as ids so a test reads as the play order it means."""
    body = f"""
      let s = {json.dumps(start) if start else "emptyQueue()"};
      for (const a of {json.dumps(actions)}) {{
        // A deterministic shuffle: reverses the order, then keepFirst is
        // pulled to the front by the reducer itself. Real randomness would
        // make every shuffle assertion a coin toss.
        s = queueReducer(s, {{ ...a, rand: () => 0 }});
      }}
      console.log(JSON.stringify({{
        play: s.order.map((i) => s.tracks[i].id),
        pos: s.pos,
        playing: s.order.length ? s.tracks[s.order[s.pos]].id : null,
        nTracks: s.tracks.length,
      }}));
    """
    return _run(tmp_path, module_source, body)


# ── replace: the path that already worked, pinned ────────────────────────────

def test_replace_unshuffled_is_the_plain_order_from_the_requested_index(
        tmp_path, module_source):
    """What playing track 3 of an album has always done. If this changes,
    every album in the application changed with it."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c", "d"), "startIndex": 2},
    ])
    assert out["play"] == ["a", "b", "c", "d"]
    assert out["pos"] == 2
    assert out["playing"] == "c"


def test_replace_shuffled_keeps_the_requested_track_first(tmp_path, module_source):
    """Shuffling on a chosen track must not start a different one."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c", "d"),
         "startIndex": 2, "shuffle": True},
    ])
    assert out["pos"] == 0
    assert out["playing"] == "c"
    assert sorted(out["play"]) == ["a", "b", "c", "d"]


def test_replace_discards_the_previous_queue(tmp_path, module_source):
    """Loading a playlist replaces; it does not accumulate (docs §9.1)."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b")},
        {"type": "replace", "tracks": _tracks("x", "y", "z")},
    ])
    assert out["play"] == ["x", "y", "z"]
    assert out["nTracks"] == 3


# ── append and play-next ─────────────────────────────────────────────────────

def test_append_to_an_empty_queue_plays_it(tmp_path, module_source):
    """Enqueueing with nothing playing has to start something, or the button
    does nothing at all the first time it is pressed."""
    out = _reduce(tmp_path, module_source, [
        {"type": "append", "tracks": _tracks("a", "b")},
    ])
    assert out["play"] == ["a", "b"]
    assert out["playing"] == "a"


def test_append_goes_to_the_end_and_does_not_move_the_playhead(
        tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c"), "startIndex": 1},
        {"type": "append", "tracks": _tracks("x", "y")},
    ])
    assert out["play"] == ["a", "b", "c", "x", "y"]
    assert out["playing"] == "b"


def test_play_next_lands_immediately_after_what_is_playing(
        tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c"), "startIndex": 0},
        {"type": "insertNext", "tracks": _tracks("x")},
    ])
    assert out["play"] == ["a", "x", "b", "c"]
    assert out["playing"] == "a"


def test_play_next_on_the_last_track_still_lands_after_it(tmp_path, module_source):
    """The splice index is past the end of `order`; a slice must cope rather
    than dropping the entry silently."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b"), "startIndex": 1},
        {"type": "insertNext", "tracks": _tracks("x")},
    ])
    assert out["play"] == ["a", "b", "x"]
    assert out["playing"] == "b"


def test_play_next_while_shuffled_inserts_into_the_play_order(
        tmp_path, module_source):
    """Not into `tracks` — the played sequence is `order`, and "next" means
    next in what is actually being played."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c", "d"),
         "startIndex": 0, "shuffle": True},
        {"type": "insertNext", "tracks": _tracks("x")},
    ])
    assert out["play"][0] == "a"
    assert out["play"][1] == "x"
    assert out["playing"] == "a"


def test_appending_nothing_is_not_a_change(tmp_path, module_source):
    """An album card with no tracks must not clear the playhead."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b"), "startIndex": 1},
        {"type": "append", "tracks": []},
    ])
    assert out["play"] == ["a", "b"]
    assert out["playing"] == "b"


# ── the defect this module exists to prevent ─────────────────────────────────

def test_two_appends_in_one_tick_do_not_write_indices_past_the_end(
        tmp_path, module_source):
    """*The* reason the queue is a reducer (docs §9.3).

    Two `useState`s updated from one event both read the length captured when
    the handler was built, so the second append's indices collide with the
    first's — a double click on "add to queue" produced a queue playing the
    wrong tracks, or holes. Folding through the reducer is what a batched
    render does, and every index has to be distinct and in range.
    """
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b")},
        {"type": "append", "tracks": _tracks("x")},
        {"type": "append", "tracks": _tracks("y")},
    ])
    assert out["play"] == ["a", "b", "x", "y"], (
        "an append read a stale track count — this is the three-useState bug")
    assert out["nTracks"] == 4


def test_many_appends_stay_in_range(tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a")},
    ] + [{"type": "append", "tracks": _tracks(f"t{n}")} for n in range(20)])
    assert out["play"] == ["a"] + [f"t{n}" for n in range(20)]
    assert out["nTracks"] == 21


# ── removal ──────────────────────────────────────────────────────────────────

def test_removing_the_playing_track_slides_the_next_one_in(tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c"), "startIndex": 1},
        {"type": "removeAt", "at": 1},
    ])
    assert out["play"] == ["a", "c"]
    assert out["playing"] == "c"


def test_removing_before_the_playhead_keeps_the_same_track_playing(
        tmp_path, module_source):
    """The index moved; what is playing must not."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c"), "startIndex": 2},
        {"type": "removeAt", "at": 0},
    ])
    assert out["play"] == ["b", "c"]
    assert out["playing"] == "c"


def test_removing_the_last_remaining_track_does_not_leave_pos_dangling(
        tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a")},
        {"type": "removeAt", "at": 0},
    ])
    assert out["play"] == []
    assert out["pos"] == 0
    assert out["playing"] is None


def test_removing_out_of_range_is_not_a_change(tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b"), "startIndex": 1},
        {"type": "removeAt", "at": 7},
    ])
    assert out["play"] == ["a", "b"]
    assert out["playing"] == "b"


# ── shuffle ──────────────────────────────────────────────────────────────────

def test_shuffling_on_mid_album_does_not_interrupt_what_is_playing(
        tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c", "d"), "startIndex": 2},
        {"type": "reshuffle", "shuffle": True},
    ])
    assert out["pos"] == 0
    assert out["playing"] == "c"
    assert sorted(out["play"]) == ["a", "b", "c", "d"]


def test_shuffling_off_returns_to_the_album_order_at_the_same_track(
        tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b", "c", "d"),
         "startIndex": 2, "shuffle": True},
        {"type": "reshuffle", "shuffle": False},
    ])
    assert out["play"] == ["a", "b", "c", "d"]
    assert out["playing"] == "c"


def test_shuffle_after_an_append_covers_the_appended_tracks(
        tmp_path, module_source):
    """`order` is rebuilt from `tracks.length`, so it has to have grown."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b")},
        {"type": "append", "tracks": _tracks("x", "y")},
        {"type": "reshuffle", "shuffle": True},
    ])
    assert sorted(out["play"]) == ["a", "b", "x", "y"]
    assert out["playing"] == "a"


def test_shuffling_a_queue_holding_the_same_track_twice_keeps_the_right_copy(
        tmp_path, module_source):
    """A queue could not hold duplicates until "add to queue" existed, and the
    first reshuffle recovered the current index by searching `tracks` for the
    playing entry's id — which finds the *first* copy. Enqueue a track that is
    already in the queue, play the second copy, toggle shuffle, and playback
    jumped backwards. `order[pos]` is the index and needs no search."""
    out = _reduce(tmp_path, module_source, [
        {"type": "replace", "tracks": _tracks("a", "b")},
        {"type": "append", "tracks": _tracks("a")},
        {"type": "skipTo", "pos": 2},
        {"type": "reshuffle", "shuffle": False},
    ])
    assert out["pos"] == 2, "the reshuffle jumped to the first copy of 'a'"
    assert out["playing"] == "a"


def test_reshuffling_an_empty_queue_does_not_throw(tmp_path, module_source):
    out = _reduce(tmp_path, module_source, [
        {"type": "reshuffle", "shuffle": True},
    ])
    assert out["play"] == []
    assert out["pos"] == 0