summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_queue_ops.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 11:01:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 11:01:02 +0200
commit20706fb9a4ec646816b44a10842aa8f58ea0fd75 (patch)
tree126be97d794491196c6033ae76d4680f24188595 /packages/meshbay-hub/tests/test_queue_ops.py
parenta79a38a22a6145c475f50eeadb79b451aee31c11 (diff)
downloadmeshbay-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.py319
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