From 459fc93e98f23e326c2fa77fe86ba74c2bae77f0 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 15 Sep 2026 17:01:06 +0200 Subject: feat(hub): Videos and Music list their cards a page at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous/next arrows in the pinned toolbar, on group pages and in Search. Page size is an account preference (Settings โ†’ Defaults), 50 by default, 10 to 200 in steps of 10. Co-Authored-By: Claude Opus 5 --- .../tests/harness/sticky_header_probe.py | 12 +++- packages/meshbay-hub/tests/test_hook_ordering.py | 2 +- packages/meshbay-hub/tests/test_media_pager.py | 71 ++++++++++++++++++++++ packages/meshbay-hub/tests/test_sticky_header.py | 13 ++++ .../meshbay-hub/tests/test_transport_contracts.py | 2 +- 5 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_media_pager.py (limited to 'packages/meshbay-hub/tests') diff --git a/packages/meshbay-hub/tests/harness/sticky_header_probe.py b/packages/meshbay-hub/tests/harness/sticky_header_probe.py index 320a2d4..ee1e61b 100755 --- a/packages/meshbay-hub/tests/harness/sticky_header_probe.py +++ b/packages/meshbay-hub/tests/harness/sticky_header_probe.py @@ -185,7 +185,11 @@ for (let i = 0; i < 40; i++) add({ name: `${ARTISTS[i % ARTISTS.length]} au Zenith (2019) 1080p.mkv`, path: 'films', type: 'video', display_title: `${ARTISTS[i % ARTISTS.length]} au Zenith` }); -for (let a = 0; a < 20; a++) +// Forty films and forty albums against a page of thirty, so Videos and Music +// draw their page arrows in the toolbar being measured (pager.js) โ€” a band +// that only ever gets them past fifty would never be measured with them. +const PAGE_SIZE = '30'; +for (let a = 0; a < 40; a++) for (let t = 0; t < 2; t++) add({ name: `${t + 1} track.flac`, path: `musique/${ARTISTS[a % ARTISTS.length]} ${a}/disque ${t}`, @@ -319,7 +323,7 @@ const settle = () => new Promise((r) => render(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: 'files' }} />`, + userPrefs=${{ default_tab: 'files', media_page_size: PAGE_SIZE }} />`, document.getElementById('root')); } else { render(html`<${SearchPage} token="t" username="me" userId="u1" @@ -329,7 +333,7 @@ const settle = () => new Promise((r) => not. */''} groups=${[{ id: 'g1', name: 'musique_de_la_maison_2024', owner_username: 'cbesson' }]} - userPrefs=${{}} />`, document.getElementById('root')); + userPrefs=${{ media_page_size: PAGE_SIZE }} />`, document.getElementById('root')); // Results only exist once an index has arrived; the view toggle is drawn // with them. const field = await waitFor('.search-bar input'); @@ -377,6 +381,8 @@ const settle = () => new Promise((r) => bottomScrollY: Math.round(scrollY), scrollY: scrolledBy, room: Math.round(room), overflowX: overflowX(), + // Whether the toolbar carried its page arrows while it was measured. + pager: !!document.querySelector('.video-toolbar > .tb-pager'), // Whether the Search page's group column is on screen. It is dropped at // phone widths, where the file name needs every pixel it can get. groupColumn: (() => { diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 3e9c968..28ec93e 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -35,7 +35,7 @@ APP = STATIC / "app.js" STATIC_FILES = [ "app.js", "group-page.js", "chat-app.js", "files-app.js", "video-player.js", "video-app.js", "music-app.js", "music-player.js", - "photos-app.js", + "photos-app.js", "pager.js", "group-settings.js", # The per-app settings architecture (docs/refactor-groups.md ยง3). Reached # through the apps.js registry rather than imported by name, so a file diff --git a/packages/meshbay-hub/tests/test_media_pager.py b/packages/meshbay-hub/tests/test_media_pager.py new file mode 100644 index 0000000..a8a0569 --- /dev/null +++ b/packages/meshbay-hub/tests/test_media_pager.py @@ -0,0 +1,71 @@ +""" +Videos and Music show their cards a page at a time (`pager.js`). + +The page size is a hub preference, so two things can go wrong without any +error on screen: a stored value the client misreads, and a key the hub refuses +โ€” the Settings select would snap back and nothing would say why. The functions +are read out of `pager.js` rather than copied, and the key out of both files. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" +PAGER = ROOT / "static" / "pager.js" +USERS = ROOT / "api" / "users.py" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not PAGER.exists(), + reason="node or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def source(): + text = PAGER.read_text(encoding="utf-8") + consts = re.findall(r"^export const PAGE_SIZE_\w+ = [^;]+;", text, re.M) + funcs = [re.search(rf"^export function {name}\(.*?^\}}", text, re.M | re.S) + for name in ("pageSizeFrom", "pageBounds")] + assert len(consts) == 4 and all(funcs), "pager.js no longer has what this test reads" + return "\n".join(consts + [m.group(0) for m in funcs]).replace("export ", "") + + +def _run(tmp_path, source, expr): + script = tmp_path / "case.js" + script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));") + out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True) + return json.loads(out.stdout) + + +@pytest.mark.parametrize("stored, expected", [ + (None, 50), ("", 50), ("30", 30), ("10", 10), ("200", 200), + ("0", 50), ("210", 50), ("35", 50), ("abc", 50), ("-10", 50), +]) +def test_page_size_from_preference(tmp_path, source, stored, expected): + prefs = {} if stored is None else {"media_page_size": stored} + assert _run(tmp_path, source, f"pageSizeFrom({json.dumps(prefs)})") == expected + + +@pytest.mark.parametrize("total, size, page, expected", [ + (0, 50, 0, {"page": 0, "last": 0, "start": 0, "end": 0}), + (120, 50, 0, {"page": 0, "last": 2, "start": 0, "end": 50}), + (120, 50, 2, {"page": 2, "last": 2, "start": 100, "end": 120}), + (100, 50, 1, {"page": 1, "last": 1, "start": 50, "end": 100}), + # The list shrank under the reader: the last page that exists, not an empty one. + (60, 50, 4, {"page": 1, "last": 1, "start": 50, "end": 60}), + (60, 50, -1, {"page": 0, "last": 1, "start": 0, "end": 50}), +]) +def test_page_bounds(tmp_path, source, total, size, page, expected): + assert _run(tmp_path, source, f"pageBounds({total}, {size}, {page})") == expected + + +def test_hub_accepts_the_key_the_client_writes(): + key = re.search(r"^export const PAGE_SIZE_PREF = '([^']+)';", + PAGER.read_text(encoding="utf-8"), re.M).group(1) + allowed = re.search(r"ALLOWED_PREF_KEYS = frozenset\(\[(.*?)\]\)", + USERS.read_text(encoding="utf-8"), re.S).group(1) + assert f'"{key}"' in allowed diff --git a/packages/meshbay-hub/tests/test_sticky_header.py b/packages/meshbay-hub/tests/test_sticky_header.py index 32b8124..741b57f 100644 --- a/packages/meshbay-hub/tests/test_sticky_header.py +++ b/packages/meshbay-hub/tests/test_sticky_header.py @@ -78,6 +78,19 @@ def test_every_view_was_reached(measured): f"expected {EXPECTED_CASES} measurements, got {len(measured)}") +def test_media_toolbars_were_measured_with_their_page_arrows(measured): + """ + Videos and Music put previous/next arrows in the pinned toolbar once a + library is longer than a page (`pager.js`). The fixture sets a page smaller + than its library so every measurement above covers the toolbar with them; + this fails the fixture if that stops being true. + """ + media = [c for c in measured if c["name"].split(" @")[0].endswith(("videos", "music"))] + assert media, "no Videos or Music case was measured" + bare = [c["name"] for c in media if not c.get("pager")] + assert not bare, "measured without page arrows: " + ", ".join(bare) + + def test_the_page_really_scrolled(measured): """ Everything below compares a scrolled page with an unscrolled one. A view diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index f780073..d13cfd4 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -31,7 +31,7 @@ CREATE_GROUP = STATIC / "create-group-page.js" SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", STATIC / "video-player.js", STATIC / "video-app.js", STATIC / "music-app.js", STATIC / "music-player.js", - STATIC / "photos-app.js", + STATIC / "photos-app.js", STATIC / "pager.js", STATIC / "group-settings.js", # Same reason as test_hook_ordering's STATIC_FILES: these are # reached through the registry, so leaving one out here means it -- cgit v1.2.3