import json import subprocess from pathlib import Path import pytest from src.frontend_bundle import build_frontend from tests.dashboard_bundle import dashboard CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-pull-refresh.js" def run_scenario(source: str) -> dict: script = f""" const createPullRefresh = require({json.dumps(str(CONTROLLER))}); class FakeElement {{ constructor() {{ this.listeners = {{}}; this.dataset = {{}}; this.hidden = true; this.textContent = ''; }} addEventListener(name, callback) {{ this.listeners[name] = callback; }} removeEventListener(name) {{ delete this.listeners[name]; }} setPointerCapture() {{}} releasePointerCapture() {{}} }} const surface = new FakeElement(); const indicator = new FakeElement(); const plain = {{closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100}}; const event = (x, y, target=plain, id=1) => ({{ pointerId:id, pointerType:'touch', clientX:x, clientY:y, target, preventDefault() {{ this.prevented = true; }}, }}); {source} """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr return json.loads(result.stdout) def test_mobile_pull_refresh_runs_one_refresh_after_vertical_threshold_and_announces_success(): result = run_scenario(""" let calls = 0; const controller = createPullRefresh({ surface, indicator, threshold:64, refresh: async () => { calls += 1; }, isMobile: () => true, getScrollY: () => 0, isBlocked: () => false, }); controller.start(); surface.listeners.pointerdown(event(100, 10)); surface.listeners.pointermove(event(102, 90)); const ready = {hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent}; const completion = surface.listeners.pointerup(event(102, 90)); Promise.resolve(completion).then(() => process.stdout.write(JSON.stringify({ calls, ready, final:{hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent}, }))); """) assert result == { "calls": 1, "ready": {"hidden": False, "state": "ready", "text": "Release to refresh"}, "final": {"hidden": False, "state": "success", "text": "My Work is up to date"}, } def test_mobile_pull_refresh_ignores_ineligible_gestures_and_short_pulls(): result = run_scenario(""" let calls = 0; let mobile = true; let scrollY = 0; let blocked = false; const controller = createPullRefresh({ surface, indicator, threshold:64, refresh: async () => { calls += 1; }, isMobile: () => mobile, getScrollY: () => scrollY, isBlocked: () => blocked, }); controller.start(); async function drag(fromX, fromY, toX, toY, target=plain) { surface.listeners.pointerdown(event(fromX, fromY, target)); surface.listeners.pointermove(event(toX, toY, target)); await surface.listeners.pointerup(event(toX, toY, target)); } (async () => { await drag(100, 10, 102, 50); await drag(100, 10, 180, 30); mobile = false; await drag(100, 10, 100, 100); mobile = true; scrollY = 1; await drag(100, 10, 100, 100); scrollY = 0; blocked = true; await drag(100, 10, 100, 100); blocked = false; const interactive = {closest: selector => selector ? interactive : null, parentElement:null, scrollWidth:100, clientWidth:100}; await drag(100, 10, 100, 100, interactive); const scroller = {closest: () => null, parentElement:null, scrollWidth:300, clientWidth:100}; await drag(100, 10, 100, 100, scroller); process.stdout.write(JSON.stringify({calls})); })(); """) assert result == {"calls": 0} def test_mobile_pull_refresh_stays_single_flight_and_keeps_refreshing_status(): result = run_scenario(""" let calls = 0; let finish; const pending = new Promise(resolve => { finish = resolve; }); const controller = createPullRefresh({ surface, indicator, threshold:64, refresh: () => { calls += 1; return pending; }, isMobile: () => true, getScrollY: () => 0, isBlocked: () => false, }); controller.start(); function pull(id) { surface.listeners.pointerdown(event(100, 10, plain, id)); surface.listeners.pointermove(event(100, 90, plain, id)); return surface.listeners.pointerup(event(100, 90, plain, id)); } const first = pull(1); Promise.resolve().then(async () => { const second = pull(2); const during = {calls, state:indicator.dataset.state, text:indicator.textContent}; finish(); await first; await second; process.stdout.write(JSON.stringify({calls, during, final:indicator.dataset.state})); }); """) assert result == { "calls": 1, "during": {"calls": 1, "state": "refreshing", "text": "Refreshing My Work…"}, "final": "success", } def test_mobile_pull_refresh_announces_failure_and_allows_a_retry(): result = run_scenario(""" let calls = 0; const controller = createPullRefresh({ surface, indicator, threshold:64, refresh: async () => { calls += 1; if (calls === 1) throw new Error('offline secret'); }, isMobile: () => true, getScrollY: () => 0, isBlocked: () => false, }); controller.start(); async function pull(id) { surface.listeners.pointerdown(event(100, 10, plain, id)); surface.listeners.pointermove(event(100, 90, plain, id)); try { await surface.listeners.pointerup(event(100, 90, plain, id)); } catch (_) {} } (async () => { await pull(1); const failed = {state:indicator.dataset.state, text:indicator.textContent}; await pull(2); process.stdout.write(JSON.stringify({calls, failed, final:indicator.dataset.state})); })(); """) assert result == { "calls": 2, "failed": {"state": "error", "text": "Could not refresh · pull to retry"}, "final": "success", } @pytest.mark.anyio async def test_mobile_pull_refresh_is_wired_to_live_refresh_and_packaged_offline(): html = await dashboard() frontend = CONTROLLER.parent build = build_frontend(frontend) today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode() assert 'id="mobile-pull-refresh"' in html assert 'role="status"' in html assert html.index('static/mobile-pull-refresh.js') < html.index('static/dashboard.js') assert "createMobilePullRefresh" in html assert "contextPoller.refresh({ force: true })" in html assert "createMobilePullRefresh" in today_bundle assert "liveDataStatus" in today_bundle assert build.feature_bundles["today-timer"].runtime_name in build.service_worker_source