import json import subprocess from pathlib import Path CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js" def run_controller_scenario(scenario: str, *, focus_within: bool = False) -> dict: harness = f""" const createController = require({json.dumps(str(CONTROLLER))}); class Target {{ constructor() {{ this.listeners = {{}}; }} addEventListener(name, handler) {{ (this.listeners[name] ||= new Set()).add(handler); }} removeEventListener(name, handler) {{ this.listeners[name]?.delete(handler); }} dispatch(name, event = {{}}) {{ for (const handler of this.listeners[name] || []) handler(event); }} count(name) {{ return this.listeners[name]?.size || 0; }} }} function element() {{ const target = new Target(); target.style = {{ values: {{}}, setProperty(name, value) {{ this.values[name] = value; }}, removeProperty(name) {{ delete this.values[name]; }}, }}; target.scrollTop = 0; target.reveals = []; target.scrollIntoView = options => target.reveals.push(options); target.contains = candidate => candidate === target || candidate?.workspace === target; return target; }} const viewport = new Target(); viewport.offsetTop = 12; viewport.height = 360; const mediaQuery = {{matches:true}}; const panel = element(); const workspace = element(); const composer = element(); composer.workspace = workspace; const submit = element(); submit.workspace = workspace; const status = element(); status.workspace = workspace; const document = {{activeElement:null}}; const scheduled = []; const controller = createController({{ entries:[{{panel, workspace, composer, submit, status, focusWithin:{json.dumps(focus_within)}}}], viewport, mediaQuery, document, schedule: callback => scheduled.push(callback), }}); function flush() {{ while (scheduled.length) scheduled.shift()(); }} {scenario} """ result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) return json.loads(result.stdout) def test_mobile_composer_uses_visual_viewport_and_reveals_delivery_controls(): result = run_controller_scenario( """ controller.start(); panel.scrollTop = 240; document.activeElement = composer; composer.dispatch('focus'); viewport.offsetTop = 20; viewport.height = 300; viewport.dispatch('resize'); flush(); process.stdout.write(JSON.stringify({ geometry: panel.style.values, resizeListeners: viewport.count('resize'), scrollListeners: viewport.count('scroll'), revealCount: workspace.reveals.length, revealOptions: workspace.reveals.at(-1), })); """ ) assert result == { "geometry": { "--composer-viewport-top": "20px", "--composer-viewport-height": "300px", }, "resizeListeners": 1, "scrollListeners": 1, "revealCount": 2, "revealOptions": {"block": "end", "inline": "nearest"}, } def test_blur_restores_scroll_and_close_clears_geometry_without_duplicate_listeners(): result = run_controller_scenario( """ controller.start(); controller.start(); panel.scrollTop = 240; document.activeElement = composer; composer.dispatch('focus'); composer.dispatch('focus'); document.activeElement = null; workspace.dispatch('focusout'); flush(); const afterBlur = {scrollTop:panel.scrollTop, geometry:{...panel.style.values}, resize:viewport.count('resize')}; panel.scrollTop = 99; document.activeElement = composer; composer.dispatch('focus'); controller.close(panel); flush(); process.stdout.write(JSON.stringify({ afterBlur, afterClose:{scrollTop:panel.scrollTop, geometry:panel.style.values, resize:viewport.count('resize'), scroll:viewport.count('scroll')}, })); """ ) assert result == { "afterBlur": {"scrollTop": 240, "geometry": {}, "resize": 0}, "afterClose": {"scrollTop": 99, "geometry": {}, "resize": 0, "scroll": 0}, } def test_desktop_and_missing_visual_viewport_are_noop(): result = run_controller_scenario( """ mediaQuery.matches = false; controller.start(); document.activeElement = composer; composer.dispatch('focus'); process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewport.count('resize'), reveals:workspace.reveals.length})); """ ) assert result == {"geometry": {}, "resize": 0, "reveals": 0} def test_focus_within_reveals_each_active_capture_field_without_duplicate_viewport_listeners(): result = run_controller_scenario( """ controller.start(); panel.scrollTop = 180; document.activeElement = composer; workspace.dispatch('focusin', {target:composer}); document.activeElement = submit; workspace.dispatch('focusin', {target:submit}); viewport.dispatch('resize'); flush(); process.stdout.write(JSON.stringify({ composerReveals:composer.reveals.length, submitReveals:submit.reveals.length, workspaceReveals:workspace.reveals.length, resizeListeners:viewport.count('resize'), scrollListeners:viewport.count('scroll'), geometry:panel.style.values, })); """, focus_within=True, ) assert result == { "composerReveals": 1, "submitReveals": 2, "workspaceReveals": 0, "resizeListeners": 1, "scrollListeners": 1, "geometry": { "--composer-viewport-top": "12px", "--composer-viewport-height": "360px", }, }