From 9cbff21274604e37c0986d57937deef85819c396 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 16 Sep 2026 12:10:25 +0200 Subject: music: the playlist menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One button in Music's sticky toolbar — load, create, delete, remove a track, sync now — and "add to playlist" on every cover and row. Both surfaces share one list, read from the manifest, so they open instantly with every node offline and no body is fetched until one is wanted. Submenus expand in place rather than flying out: the account menu's language list already does this, and a flyout has nowhere to go at 400px. The tracklist under "remove a track" loads when it is expanded. A name is typed into a field. Electron has no prompt — it throws. Also splits the two playback failures: a decode failure belongs to that file and keeps the bounded counter, a connection failure belongs to the group and skips all of its queued tracks at once. Six dead tracks are one more than the bound, which is where a playlist would otherwise stop. Co-Authored-By: Claude Opus 5 --- .../meshbay-hub/tests/harness/music_queue_probe.py | 58 +++- .../meshbay-hub/tests/harness/playlist_ui_probe.py | 368 +++++++++++++++++++++ packages/meshbay-hub/tests/test_hook_ordering.py | 2 +- packages/meshbay-hub/tests/test_music_queue.py | 27 +- packages/meshbay-hub/tests/test_playlist_ui.py | 108 ++++++ .../meshbay-hub/tests/test_transport_contracts.py | 2 +- 6 files changed, 555 insertions(+), 10 deletions(-) create mode 100644 packages/meshbay-hub/tests/harness/playlist_ui_probe.py create mode 100644 packages/meshbay-hub/tests/test_playlist_ui.py (limited to 'packages/meshbay-hub/tests') diff --git a/packages/meshbay-hub/tests/harness/music_queue_probe.py b/packages/meshbay-hub/tests/harness/music_queue_probe.py index f74ceb2..a9146ce 100755 --- a/packages/meshbay-hub/tests/harness/music_queue_probe.py +++ b/packages/meshbay-hub/tests/harness/music_queue_probe.py @@ -119,12 +119,24 @@ function Harness() { const onPlayQueue = useCallback((tracks, startIndex, source, op) => { setQueue({ tracks, startIndex, nonce: Date.now(), op: op || 'replace' }); }, []); + // A queue that crosses groups cannot be built from one group's page, and + // that is the case the skipping rule exists for — so the probe reaches in + // for that one step rather than pretending a gesture builds it. + window.__probePlayQueue = onPlayQueue; + + // Rejects at once for a group nothing serves, and hangs for every other — + // hanging is what the stub node does anyway, and what matters here is which + // track the playhead lands on, not whether anything plays. + const getConnection = (groupId) => (String(groupId).startsWith('dead') + ? Promise.reject(new Error('no node')) + : new Promise(() => {})); + return html` <${GroupPage} groupId="g1" token="t" username="me" userId="u1" group=${{ id: 'g1', name: 'un groupe', owner_username: 'me', is_admin: false }} userPrefs=${{ default_tab: 'music', media_page_size: '50' }} onPlayQueue=${onPlayQueue} /> - ${queue && html`<${MusicPlayerBar} getConnection=${() => new Promise(() => {})} + ${queue && html`<${MusicPlayerBar} getConnection=${getConnection} queue=${queue} userPrefs=${{}} onClose=${() => setQueue(null)} />`} `; } @@ -171,9 +183,29 @@ const clickMenu = async (i) => { await initLocale(); render(html`<${Harness} />`, document.getElementById('root')); - if (!await waitFor('.music-card')) return fail('no album grid'); + // Wait for the grid to be *complete*, not merely present — and scroll, + // because it will not complete otherwise: the tiles mount on intersection + // (`LazyTile`), so an album below the fold has no card at all until the + // page is scrolled to it. A probe that counts what happens to be on screen + // is measuring the height of its own iframe; adding one button to the + // toolbar was enough to push the fourth album out of view. + const waitForCount = async (sel, n, tries = 80) => { + for (let i = 0; i < tries; i++) { + if (document.querySelectorAll(sel).length >= n) { + scrollTo(0, 0); + await sleep(100); + return true; + } + scrollTo(0, document.documentElement.scrollHeight); + await sleep(50); + } + return false; + }; + if (!await waitForCount('.music-card', 4)) { + return fail('expected 4 albums, got ' + + document.querySelectorAll('.music-card').length); + } const cards = [...document.querySelectorAll('.music-card')]; - if (cards.length < 4) return fail('expected 4 albums, got ' + cards.length); // 1. Open album 1 and play its second track — the ordinary path, through a // row that is no longer one big button. @@ -219,6 +251,26 @@ const clickMenu = async (i) => { await sleep(250); await queueNow('replace with album 4'); + // 6. A group that does not answer: every one of its queued tracks is + // skipped in one step, not one failure at a time against a bound that + // was sized for corrupt files. + const mixed = (id, group) => ({ + id, name: id + '.flac', display_title: id, path: 'p', size: 10, + type: 'audio', duration: 100, groupId: group, + }); + window.__probePlayQueue([ + mixed('live-1', 'g1'), + mixed('dead-1', 'dead-a'), mixed('dead-2', 'dead-a'), + mixed('dead-3', 'dead-a'), mixed('dead-4', 'dead-a'), + mixed('dead-5', 'dead-a'), mixed('dead-6', 'dead-a'), + mixed('live-2', 'g1'), + ], 1, null, 'replace'); + await sleep(1200); + steps.push({ + step: 'an unreachable group is skipped whole', + nowPlaying: (document.querySelector('.music-player-title') || {}).textContent || null, + }); + parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*'); } catch (err) { fail(String((err && err.stack) || err)); diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py new file mode 100644 index 0000000..f0fc9c3 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" +The playlist menus, pressed in a real browser. + +`playlists.js` is covered against a stubbed node by `playlist_store_probe.py`, +and the merge and the sealing by their own tests. None of that reaches the part +a person actually touches: whether the toolbar button opens a menu, whether +naming a playlist in a field works (Electron has no `prompt` — it throws), and +whether "add to playlist" on an album cover puts the right tracks in the right +playlist. + +So this renders the shipped `GroupPage`, `MusicPlayerBar` and playlist menus, +presses the real controls, and reads the result back out of the store. + + playlist_ui_probe.py + +Prints JSON: one entry per step. +""" + +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8755 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r""" + + +
+ +""" + +PAGE = r""" +
""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + elif path == "/case": + self._send(FRAME.encode(), "text/html; charset=utf-8") + elif path == "/v1/groups/g1/nodes": + self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(400): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 994ca2e..78561b6 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -51,7 +51,7 @@ STATIC_FILES = [ "search-page.js", # The shared pop-up menu (docs/playlists.md §10.1), reached from the media # views rather than imported by the shell. - "menu.js", + "menu.js", "playlist-menu.js", ] pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") diff --git a/packages/meshbay-hub/tests/test_music_queue.py b/packages/meshbay-hub/tests/test_music_queue.py index a53047d..5bf9e97 100644 --- a/packages/meshbay-hub/tests/test_music_queue.py +++ b/packages/meshbay-hub/tests/test_music_queue.py @@ -52,11 +52,12 @@ def test_playing_a_track_queues_its_album(steps): 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_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): @@ -96,6 +97,22 @@ def test_play_all_replaces_everything(steps): 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 diff --git a/packages/meshbay-hub/tests/test_playlist_ui.py b/packages/meshbay-hub/tests/test_playlist_ui.py new file mode 100644 index 0000000..c449dc7 --- /dev/null +++ b/packages/meshbay-hub/tests/test_playlist_ui.py @@ -0,0 +1,108 @@ +""" +The playlist menus, pressed in a real browser. + +The store is covered against a stubbed node, and the merge and the sealing by +their own tests. None of that reaches the part a person touches: whether the +toolbar button opens a menu at all, whether naming a playlist works (Electron +has no `prompt` — it *throws*, which is how the Files toolbar's New folder +button came to do nothing), and whether "add to playlist" on a cover puts the +right tracks in the right playlist. + +The probe renders the shipped `GroupPage`, `MusicPlayerBar` and playlist menus +and presses the real controls. Labels come back in whatever locale the browser +picked, so what is asserted is shape and behaviour — counts, order, and what +ended up in the store — rather than English strings. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "playlist_ui_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 / "playlist-menu.js").exists(), + reason="Chrome or the SPA sources are not available") + + +@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_one_button_opens_the_five_playlist_verbs_in_order(steps): + """Load, create, delete, remove a track — the order asked for — and Sync + now, which has to live somewhere and this is the only playlist surface.""" + assert len(steps["toolbar menu"]["items"]) == 5 + + +def test_a_playlist_is_named_in_a_field_and_not_a_prompt(steps): + """`window.prompt` throws in Electron and does not return null, so it is + banned outright (`test_no_prompt_in_the_spa.py`). Anything that needs typed + input needs a field, and this is the one that does.""" + s = steps["created"] + assert s["modalGone"] is True, "the modal stayed open, so nothing was saved" + assert [p["name"] for p in s["lists"]] == ["Soirée"] + assert s["lists"][0]["count"] == 0 + + +def test_the_cover_menu_carries_the_queue_verbs_and_add_to_playlist(steps): + assert len(steps["cover menu"]["items"]) == 4 + + +def test_favourites_is_offered_first_before_it_has_ever_been_used(steps): + """The reserved playlist is materialised on first use, so the submenu has + it on a fresh account — and has it first, as the design promises.""" + items = steps["add-to submenu"]["items"] + submenu = items[4:] + assert len(submenu) == 3, submenu + assert submenu[1] == "Soirée" + + +def test_adding_an_album_from_its_cover_puts_its_tracks_in_the_playlist(steps): + s = steps["added to the playlist"] + assert s["lists"] == [{"name": "Soirée", "count": 3}] + assert s["note"], "nothing said it had happened" + + +def test_loading_a_playlist_replaces_the_queue_with_its_tracks(steps): + """Below `onPlayQueue` a playlist and an album are indistinguishable, which + is why auto-advance, shuffle and prefetch are unchanged by construction.""" + assert steps["loaded into the queue"]["play"] == ["A2-t1", "A2-t2", "A2-t3"] + + +def test_the_tracklist_submenu_is_two_levels_and_fetched_when_expanded(steps): + """As asked: the playlist, then its tracks. The second level is read from + IndexedDB when it is opened — building it eagerly would read every + playlist's tracks to draw a menu nobody may open.""" + items = steps["the tracklist submenu"]["items"] + assert "A2-t1" in items and "A2-t3" in items + assert items.index("A2-t1") > 0 + # The tracks sit under their playlist, between it and the next top-level + # item — expanded in place rather than in a flyout. + assert items[-1] == steps["toolbar menu"]["items"][-1] + + +def test_removing_a_track_removes_that_one(steps): + s = steps["track removed"] + assert s["lists"] == [{"name": "Soirée", "count": 2}] + assert s["tracks"] == ["A2-t1", "A2-t3"], "the wrong track was removed" + + +def test_deleting_a_playlist_asks_first(steps): + """A deletion is a tombstone: there is nothing in the interface that undoes + it. `confirm` and not a component — Electron implements it and a dozen + places in this SPA already use it.""" + s = steps["deleted"] + assert s["asked"] is True, "a playlist was deleted without asking" + assert s["lists"] == [] diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 5c535a3..9c3a88b 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -43,7 +43,7 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", STATIC / "photos-app-settings.js", STATIC / "helloworld-app.js", STATIC / "helloworld-app-settings.js", - STATIC / "menu.js", + STATIC / "menu.js", STATIC / "playlist-menu.js", STATIC / "auth-page.js", STATIC / "explore-page.js", CREATE_GROUP] -- cgit v1.2.3