import json import subprocess from pathlib import Path FRONTEND = Path(__file__).resolve().parents[1] / "frontend" CONTROLLER = FRONTEND / "mobile-review-detail-nav.js" def test_review_navigation_moves_between_sections_and_focuses_feedback(): script = f""" const createNavigation = require({json.dumps(str(CONTROLLER))}); class FakeElement {{ constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; this.scrolls=[]; }} addEventListener(name, callback) {{ this.listeners[name]=callback; }} removeEventListener(name) {{ delete this.listeners[name]; }} setAttribute(name, value) {{ this.attributes[name]=value; }} removeAttribute(name) {{ delete this.attributes[name]; }} focus(options) {{ this.focuses += 1; this.focusOptions=options; }} scrollIntoView(options) {{ this.scrolls.push(options); }} }} const names=['overview','files','feedback','history']; const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)])); const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)])); const summaryComposer=new FakeElement('summary'); const navigation=createNavigation({{ buttons, targets, summaryComposer, prefersReducedMotion:() => true, }}); navigation.start(); navigation.navigate('files'); navigation.navigate('feedback'); const currentAfterFeedback=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])); navigation.reset(); const currentAfterReset=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])); navigation.stop(); process.stdout.write(JSON.stringify({{ files:targets.files.scrolls, feedback:targets.feedback.scrolls, feedbackFocuses:summaryComposer.focuses, focusOptions:summaryComposer.focusOptions, currentAfterFeedback, currentAfterReset, 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) == { "files": [{"block": "start", "behavior": "auto"}], "feedback": [{"block": "start", "behavior": "auto"}], "feedbackFocuses": 1, "focusOptions": {"preventScroll": True}, "currentAfterFeedback": { "overview": None, "files": None, "feedback": "location", "history": None, }, "currentAfterReset": { "overview": "location", "files": None, "feedback": None, "history": None, }, "listenersAfterStop": [0, 0, 0, 0], } def test_review_navigation_tracks_the_visible_section_inside_the_review_panel(): 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','files','feedback','history'].map(name => [name,makeElement(name)])); const targets=Object.fromEntries(['overview','files','feedback','history'].map(name => [name,makeElement(name)])); let callback; let observedRoot=null; let disconnected=0; const panel=makeElement('panel'); const navigation=createNavigation({{ buttons, targets, root:panel, observe(handler, observedTargets, root) {{ callback=handler; observedRoot=root; return {{disconnect() {{ disconnected += 1; }}}}; }}, }}); navigation.start(); callback([ {{target:targets.files,isIntersecting:true,intersectionRatio:.25}}, {{target:targets.feedback,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,rootMatches:observedRoot === panel,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, "files": None, "feedback": "location", "history": None, }, "rootMatches": True, "disconnected": 1, } def test_review_navigation_routes_taps_and_scrolls_without_focusing_on_restore(): script = f""" const createNavigation=require({json.dumps(str(CONTROLLER))}); const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0, addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}}, setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }}, scrollIntoView() {{}}, focus() {{ this.focuses += 1; }}, }}); const names=['overview','files','feedback','history']; const buttons=Object.fromEntries(names.map(name => [name,element(name)])); const targets=Object.fromEntries(names.map(name => [name,element(name)])); const composer=element('composer'); const changes=[]; let observer; const navigation=createNavigation({{buttons,targets,summaryComposer:composer, onSectionChange:(name,options)=>changes.push([name,options.replace]), observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }}, }}); navigation.start(); buttons.feedback.listeners.click({{preventDefault() {{}}}}); observer([{{target:targets.history,isIntersecting:true,intersectionRatio:1}}]); navigation.navigate('feedback',{{focus:false}}); process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.feedback.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": [["feedback", False], ["history", True]], "focuses": 1, "current": "location", } def test_review_sheet_wires_a_mobile_safe_area_navigation_rail_and_offline_asset(): html = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() dashboard_js = (FRONTEND / "dashboard.js").read_text() service_worker = (FRONTEND / "service-worker.js").read_text() assert '