import json import subprocess from pathlib import Path import pytest from tests.dashboard_bundle import dashboard POSITIONER = Path(__file__).resolve().parents[1] / "frontend" / "update-read-position.js" def run_positioner(script: str) -> dict: source = f"const createPositioner = require({json.dumps(str(POSITIONER))});\n" + script result = subprocess.run(["node", "-e", source], capture_output=True, text=True) assert result.returncode == 0, result.stderr return json.loads(result.stdout) def test_new_update_scrolls_to_newest_activity_once_without_reusing_previous_offset(): result = run_positioner(""" const listeners = {}; const panel = {scrollTop:480, addEventListener:(name, fn)=>listeners[name]=fn}; const jump = {hidden:true, focus(){}}; const first = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; const second = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; const positioner = createPositioner({panel, jump}); positioner.open('101'); positioner.ready('101', first); positioner.ready('101', first); panel.scrollTop = 920; positioner.open('102'); positioner.ready('102', second); process.stdout.write(JSON.stringify({first:first.calls, second:second.calls, jumpHidden:jump.hidden})); """) assert result == {"first": 1, "second": 1, "jumpHidden": True} def test_user_scroll_during_loading_preserves_position_until_explicit_jump(): result = run_positioner(""" const listeners = {}; const panel = {scrollTop:120, addEventListener:(name, fn)=>listeners[name]=fn}; const jump = {hidden:true, focus(){}}; const target = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; const positioner = createPositioner({panel, jump}); positioner.open('201'); panel.scrollTop = 260; listeners.scroll(); positioner.ready('201', target); const before = {calls:target.calls, hidden:jump.hidden, top:panel.scrollTop}; positioner.jump(); process.stdout.write(JSON.stringify({before, after:{calls:target.calls, hidden:jump.hidden}})); """) assert result == { "before": {"calls": 0, "hidden": False, "top": 260}, "after": {"calls": 1, "hidden": True}, } def test_stale_or_prefetched_conversation_cannot_move_the_visible_update(): result = run_positioner(""" const panel = {scrollTop:0, addEventListener(){}}; const jump = {hidden:true}; const stale = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; const current = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; const positioner = createPositioner({panel, jump}); positioner.open('302'); positioner.ready('301', stale); positioner.ready('302', current); process.stdout.write(JSON.stringify({stale:stale.calls, current:current.calls})); """) assert result == {"stale": 0, "current": 1} @pytest.mark.anyio async def test_dashboard_mounts_mobile_new_activity_positioning_flow(): html = await dashboard() assert '' in html assert 'id="jump-update-new-activity"' in html assert "updateReadPosition.open(String(item.notification_id))" in html assert "updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest)" in html assert ".update-new-activity" in html assert "BASE + 'static/update-read-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()