import json import subprocess from pathlib import Path CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-issue-detail-nav.js" FRONTEND = CONTROLLER.parent def test_mobile_issue_navigation_opens_and_focuses_each_destination_without_forced_motion(): script = f""" const createNavigation = require({json.dumps(str(CONTROLLER))}); class FakeElement {{ constructor(name) {{ this.name = name; this.listeners = {{}}; this.attributes = {{}}; this.open = false; this.focuses = 0; this.scrolls = []; }} addEventListener(name, callback) {{ this.listeners[name] = callback; }} removeEventListener(name) {{ delete this.listeners[name]; }} click() {{ this.listeners.click({{currentTarget:this, preventDefault() {{}}}}); }} setAttribute(name, value) {{ this.attributes[name] = value; }} removeAttribute(name) {{ delete this.attributes[name]; }} focus() {{ this.focuses += 1; }} scrollIntoView(options) {{ this.scrolls.push(options); }} }} const buttons = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,new FakeElement(name)])); const targets = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,new FakeElement(name)])); const navigation = createNavigation({{ buttons, targets, planning:targets.actions, prefersReducedMotion:() => true, }}); navigation.start(); buttons.overview.click(); buttons.conversation.click(); buttons.reply.click(); buttons.actions.click(); const currentAfterActions = Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])); navigation.stop(); process.stdout.write(JSON.stringify({{ overview:targets.overview.scrolls, conversation:targets.conversation.scrolls, reply:targets.reply.scrolls, replyFocuses:targets.reply.focuses, actions:targets.actions.scrolls, planningOpen:targets.actions.open, currentAfterActions, listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length), }})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "overview": [{"block": "start", "behavior": "auto"}], "conversation": [{"block": "start", "behavior": "auto"}], "reply": [{"block": "start", "behavior": "auto"}], "replyFocuses": 1, "actions": [{"block": "start", "behavior": "auto"}], "planningOpen": True, "currentAfterActions": { "overview": None, "conversation": None, "reply": None, "actions": "location", }, "listenersAfterStop": [0, 0, 0, 0], } def test_mobile_issue_navigation_tracks_the_visible_section_and_disconnects_observer(): script = f""" const createNavigation = require({json.dumps(str(CONTROLLER))}); const makeElement = name => ({{ name, attributes:{{}}, addEventListener() {{}}, removeEventListener() {{}}, setAttribute(key,value) {{ this.attributes[key] = value; }}, removeAttribute(key) {{ delete this.attributes[key]; }}, }}); const buttons = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,makeElement(name)])); const targets = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,makeElement(name)])); let callback; let disconnected = 0; const navigation = createNavigation({{ buttons, targets, observe(handler, observed) {{ callback = handler; return {{disconnect() {{ disconnected += 1; }}}}; }}, }}); navigation.start(); callback([ {{target:targets.overview,isIntersecting:true,intersectionRatio:.2}}, {{target:targets.reply,isIntersecting:true,intersectionRatio:.8}}, ]); const current = Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])); navigation.stop(); process.stdout.write(JSON.stringify({{current,disconnected}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "current": { "overview": None, "conversation": None, "reply": "location", "actions": None, }, "disconnected": 1, } def test_mobile_issue_navigation_distinguishes_taps_scrolls_and_keyboard_safe_restore(): script = f""" const createNavigation = require({json.dumps(str(CONTROLLER))}); class FakeElement {{ constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; }} addEventListener(name, callback) {{ this.listeners[name]=callback; }} removeEventListener() {{}} click() {{ this.listeners.click({{preventDefault() {{}}}}); }} setAttribute(name, value) {{ this.attributes[name]=value; }} removeAttribute(name) {{ delete this.attributes[name]; }} scrollIntoView() {{}} focus() {{ this.focuses += 1; }} }} const names=['overview','conversation','reply','actions']; const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)])); const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)])); const changes=[]; let observer; const navigation=createNavigation({{ buttons, targets, onSectionChange:(name, options) => changes.push([name, options.replace]), observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }}, }}); navigation.start(); buttons.reply.click(); observer([{{target:targets.actions,isIntersecting:true,intersectionRatio:1}}]); navigation.navigate('reply', {{focus:false}}); process.stdout.write(JSON.stringify({{changes,replyFocuses:targets.reply.focuses,current:buttons.reply.attributes['aria-current']}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "changes": [["reply", False], ["actions", True]], "replyFocuses": 1, "current": "location", } def test_issue_sheet_ships_a_mobile_only_safe_area_navigation_rail(): html = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() dashboard_js = (FRONTEND / "dashboard.js").read_text() assert '