diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:01:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:01:02 +0200 |
| commit | 20706fb9a4ec646816b44a10842aa8f58ea0fd75 (patch) | |
| tree | 126be97d794491196c6033ae76d4680f24188595 /packages/meshbay-hub/tests/test_queue_ops.py | |
| parent | a79a38a22a6145c475f50eeadb79b451aee31c11 (diff) | |
| download | meshbay-20706fb9a4ec646816b44a10842aa8f58ea0fd75.tar.gz | |
music: play, play next, add to queue
The player's queue could only be replaced: every onPlayQueue reset
tracks/order/pos together. It becomes one reducer (queue-ops.js) with
an `op`, because two appends batched into one tick cannot both read the
track count out of separate useStates.
A shared pop-up menu (menu.js) carries the three verbs, on right-click
and on a dots button. A track row is now a div holding two buttons: a
button cannot contain a button.
Found by the browser probe: both music wrappers took two arguments and
forwarded two, so every "add to queue" arrived as a plain play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_queue_ops.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_queue_ops.py | 319 |
1 files changed, 319 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_queue_ops.py b/packages/meshbay-hub/tests/test_queue_ops.py new file mode 100644 index 0000000..64b5ca1 --- /dev/null +++ b/packages/meshbay-hub/tests/test_queue_ops.py @@ -0,0 +1,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 |