aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:06 +0200
commit25f62e169e049f0757ef611d5b409e7523958dee (patch)
treecb755a6ab7364b1f4bc2144caf04b7310862aa13 /packages/meshbay-hub/tests/harness
parent8889e90b8f1629f983c7ed4e44a2badbda525095 (diff)
downloadmeshbay-25f62e169e049f0757ef611d5b409e7523958dee.tar.gz
music: pool single-album artists into shared rows
An artist with one album got a heading and one cover on a row that fits five, and a library is mostly single-album artists. Consecutive singles share one grid, in place, so the page stays in artist order. Each pooled cover keeps its artist's name above it in the same type as a section heading. Dropping it was the first version and it was wrong: scrolling then alternates between artists written large and small. Measured on the probe's fixture: 4208px to 1895px, and a walk of the page reaches all 21 covers instead of 9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
-rw-r--r--packages/meshbay-hub/tests/harness/music_grid_probe.py316
1 files changed, 316 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/music_grid_probe.py b/packages/meshbay-hub/tests/harness/music_grid_probe.py
new file mode 100644
index 0000000..365e028
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/music_grid_probe.py
@@ -0,0 +1,316 @@
+#!/usr/bin/env python3
+"""
+How much of the Music grid is whitespace.
+
+Each artist used to get a grid container of their own, so an artist with a
+single album got a heading and one cover on a row that fits five — and a real
+library is mostly single-album artists. Consecutive singles now share one grid.
+
+Measuring is the only way to check this: the layout is `auto-fill` over a width
+nothing declares, so what matters is the *rendered* rectangles. This reports
+every cover's position, which row it landed on, and how tall the grid is.
+
+ music_grid_probe.py
+"""
+
+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 = 8757
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div></nav>
+<div class="layout"><main class="main"><div id="root"></div></main></div>
+<script>
+// Four albums of three tracks, each track titled so the queue can be read back
+// unambiguously: "A2-t3" is the third track of the second album and nothing
+// else. A fixture whose rows cannot be told apart measures nothing.
+const ENTRIES = [];
+let n = 0;
+// Three artists with several albums each, and twelve with exactly one —
+// roughly the proportion a real library has.
+const MANY = [['Alpha', 3], ['Bravo', 2], ['Kilo', 4]];
+const ONE = ['Charlie', 'Delta', 'Echo',
+ 'Foxtrot Un Nom Vraiment Tres Long Qui Ne Tient Pas', 'Golf',
+ 'Hotel', 'India', 'Juliett', 'Lima', 'Mike', 'November', 'Oscar'];
+const add = (artist, album, i) => ENTRIES.push({
+ id: 'e' + (++n), name: `${i + 1} - t.flac`, display_title: `${album} ${i + 1}`,
+ path: `musique/${artist}/${album}`, type: 'audio', artist, album,
+ track_no: i + 1, duration: 200, size: 1024, added_at: 1750000000 + n,
+});
+for (const [artist, albums] of MANY) {
+ for (let a = 1; a <= albums; a++) {
+ for (let i = 0; i < 3; i++) add(artist, `${artist} disque ${a}`, i);
+ }
+}
+for (const artist of ONE) {
+ for (let i = 0; i < 3; i++) add(artist, `${artist} unique`, i);
+}
+
+const ACK = {
+ is_node_admin: false,
+ enabled_apps: ['files', 'music'],
+ tmdb_enabled: false, musicbrainz_enabled: false,
+ video_directories: [], music_directories: ['musique'], photo_directories: [],
+};
+
+window.MeshBayTransport = function () {
+ const self = {
+ connected: false, memberRole: 'member', supportsAppOps: true,
+ sessionKeys: null, gekRaw: null,
+ newNodeBundle: null, newNodeBundleRecovery: null,
+ async connect() { self.connected = true; return ACK; },
+ async fetchIndex() {
+ return { entries: ENTRIES, dirs: ['musique'],
+ roots: [{ name: 'musique', available: true, writable: false,
+ removable: false }] };
+ },
+ async fetchChatHistory() { return { messages: [], hasMore: false }; },
+ async fetchLinkPreview() { return { ok: false }; },
+ close() {},
+ };
+ return new Proxy(self, {
+ get(target, prop) {
+ if (prop in target) return target[prop];
+ if (typeof prop === 'string' && prop.startsWith('on')) return undefined;
+ if (typeof prop === 'symbol') return undefined;
+ return () => new Promise(() => {});
+ },
+ set(target, prop, value) { target[prop] = value; return true; },
+ });
+};
+</script>
+<script type="module">
+import { html, render, useState, useCallback } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupPage } from '/group-page.js';
+import { MusicPlayerBar } from '/music-player.js';
+
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+addEventListener('unhandledrejection',
+ (e) => LOGS.push('rejection: ' + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason)));
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const waitFor = async (sel, tries = 60) => {
+ for (let i = 0; i < tries; i++) {
+ const el = document.querySelector(sel);
+ if (el) return el;
+ await sleep(50);
+ }
+ return null;
+};
+
+// The shell's two jobs, and only those: hold the queue, and pass `op` through.
+// app.js does more (a transport fast path, the stop button); the contract this
+// exercises is the shape of what it hands the player.
+function Harness() {
+ const [queue, setQueue] = useState(null);
+ 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=${getConnection}
+ queue=${queue} userPrefs=${{}} onClose=${() => setQueue(null)} />`}
+ `;
+}
+
+const steps = [];
+
+// The queue as a person sees it: the player's own panel, opened and closed.
+async function queueNow(label, extra) {
+ const open = document.querySelector('.music-player-extra .music-player-btn');
+ if (!open) { steps.push({ step: label, bar: false, ...extra }); return; }
+ open.click();
+ const panel = await waitFor('.music-detail .music-tracklist');
+ const rows = [...document.querySelectorAll('.music-detail .music-tracklist .music-track-row')];
+ steps.push({
+ step: label,
+ bar: true,
+ play: rows.map((r) => r.querySelector('.music-track-title').textContent),
+ playing: (rows.findIndex((r) => r.classList.contains('active'))),
+ nowPlaying: (document.querySelector('.music-player-title') || {}).textContent || null,
+ ...extra,
+ });
+ const close = document.querySelector('.music-detail .video-close');
+ if (close) close.click();
+ await sleep(120);
+}
+
+const rightClick = (el) => el.dispatchEvent(new MouseEvent('contextmenu', {
+ bubbles: true, cancelable: true, clientX: 200, clientY: 200 }));
+
+const menuLabels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-item')]
+ .map((b) => b.querySelector('.ctx-menu-label').textContent);
+
+const clickMenu = async (i) => {
+ const items = [...document.querySelectorAll('.ctx-menu .ctx-menu-item')];
+ items[i].click();
+ await sleep(200);
+};
+
+(async () => {
+ const fail = (why) => parent.postMessage(
+ { error: why, logs: LOGS.slice(0, 12),
+ text: (document.getElementById('root').textContent || '').slice(0, 400) }, '*');
+ try {
+ await initLocale();
+ render(html`<${Harness} />`, document.getElementById('root'));
+
+ // Every tile mounts on intersection, so the page has to be walked.
+ for (let i = 0; i < 60; i++) {
+ scrollTo(0, document.documentElement.scrollHeight);
+ await sleep(60);
+ if (document.querySelectorAll('.music-card').length >= 21) break;
+ }
+ scrollTo(0, 0);
+ await sleep(300);
+
+ // Reported rather than asserted here: how *many* covers a walk of the page
+ // reaches is itself a property of the layout, and the caller is better
+ // placed to judge it than the probe is.
+ const cards = [...document.querySelectorAll('.music-card')];
+
+ // Group covers by the row they landed on, in document order.
+ const rows = [];
+ for (const c of cards) {
+ const r = c.getBoundingClientRect();
+ const top = Math.round(r.top + scrollY);
+ const cell = c.closest('.music-pool-cell');
+ const label = c.querySelector('.music-card-sub').textContent;
+ const heading = cell ? cell.querySelector('.music-pool-heading') : null;
+ const last = rows[rows.length - 1];
+ const entry = { artist: label, top,
+ heading: heading ? heading.textContent : null,
+ headingH: heading ? Math.round(heading.getBoundingClientRect().height) : null };
+ if (last && Math.abs(last.top - top) < 8) last.cells.push(entry);
+ else rows.push({ top, cells: [entry] });
+ }
+
+ parent.postMessage({ steps: [{
+ step: 'grid',
+ cards: cards.length,
+ rows: rows.map((r) => r.cells.map((c) => c.artist)),
+ cells: rows.map((r) => r.cells),
+ poolHeadings: [...document.querySelectorAll('.music-pool-heading')].length,
+ // A pooled cell's heading carries both classes; these are the ones that
+ // sit *above a grid* rather than inside a cell.
+ headings: [...document.querySelectorAll(
+ '.music-artist-heading:not(.music-pool-heading)')].map((h) => h.textContent),
+ pools: document.querySelectorAll('.music-artist-pool').length,
+ gridHeight: Math.round(document.querySelector('.main').scrollHeight),
+ width: innerWidth,
+ }], logs: LOGS.slice(0, 8) }, '*');
+ } catch (err) {
+ fail(String((err && err.stack) || err));
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+addEventListener('message', (e) => {
+ fetch('/log', { method: 'POST', body: JSON.stringify(e.data) });
+});
+const f = document.createElement('iframe');
+f.src = '/case';
+f.style.cssText = 'width:1100px;height:800px;border:0;display:block';
+document.getElementById('frames').appendChild(f);
+</script></body></html>"""
+
+
+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())