166 lines
5.2 KiB
Python
166 lines
5.2 KiB
Python
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",
|
|
},
|
|
}
|