stackchain-dashboard/tests/test_mobile_search_modal.py
timmy 4855bc7c8d
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m32s
CI / release-candidate (pull_request) Has been skipped
feat: contain mobile Search focus through Preview (Closes #1390)
2026-08-25 10:08:55 +00:00

111 lines
5.4 KiB
Python

import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONTROLLER = ROOT / "frontend" / "mobile-search-modal.js"
def run_node(script: str) -> dict:
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_search_preview_journey_keeps_background_inert_and_restores_prior_state():
script = f"""
const createModal = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = (name) => ({{
name, inert:false, isConnected:true, hidden:false, disabled:false, focuses:0,
focus() {{ this.focuses += 1; }},
matches() {{ return true; }},
}});
const header=element('header'), main=element('main'), dock=element('dock'); dock.inert=true;
const palette=element('palette'), preview=element('preview');
const input=element('input'), close=element('close'), opener=element('opener');
palette.querySelectorAll=()=>[input]; preview.querySelectorAll=()=>[close];
const controller=createModal({{
document:{{addEventListener(name,fn){{listeners[name]=fn;}}}},
backgrounds:[header,main,dock],
}});
controller.activate(palette, {{opener, initialFocus:input}});
const opened=[header.inert,main.inert,dock.inert,input.focuses];
controller.transition(preview, {{initialFocus:close}});
const previewed=[header.inert,main.inert,dock.inert,close.focuses,controller.current()===preview];
controller.transition(palette, {{initialFocus:input}});
const returned=[header.inert,main.inert,dock.inert,input.focuses];
controller.deactivate();
process.stdout.write(JSON.stringify({{opened,previewed,returned,restored:[header.inert,main.inert,dock.inert,opener.focuses]}}));
"""
assert run_node(script) == {
"opened": [True, True, True, 1],
"previewed": [True, True, True, 1, True],
"returned": [True, True, True, 2],
"restored": [False, False, True, 1],
}
def test_tab_and_shift_tab_wrap_dynamic_visible_controls_on_active_surface():
script = f"""
const createModal = require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=(name)=>({{name,inert:false,isConnected:true,hidden:false,disabled:false,focuses:0,
focus(){{this.focuses+=1;}},matches(selector){{return !this.hidden;}}}});
const surface=element('surface'), first=element('first'), middle=element('middle'), last=element('last');
const hidden=element('hidden'); hidden.hidden=true; const disabled=element('disabled'); disabled.disabled=true;
surface.querySelectorAll=()=>[first,hidden,middle,disabled,last];
const document={{activeElement:last,addEventListener(name,fn){{listeners[name]=fn;}}}};
const controller=createModal({{document,backgrounds:[]}});
controller.activate(surface,{{initialFocus:first}});
let prevented=0;
document.activeElement=last; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
document.activeElement=first; listeners.keydown({{key:'Tab',shiftKey:true,preventDefault(){{prevented+=1;}}}});
// A newly rendered enabled control becomes the last stop without remounting.
const dynamic=element('dynamic'); surface.querySelectorAll=()=>[first,middle,last,dynamic];
document.activeElement=dynamic; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{first:first.focuses,last:last.focuses,prevented}}));
"""
assert run_node(script) == {"first": 3, "last": 1, "prevented": 3}
def test_disconnected_opener_is_not_focused_on_final_close():
script = f"""
const createModal=require({json.dumps(str(CONTROLLER))});
const surface={{querySelectorAll(){{return[];}}}};
const opener={{isConnected:true,focuses:0,focus(){{this.focuses+=1;}}}};
const document={{addEventListener(){{}}}};
const controller=createModal({{document,backgrounds:[]}});
controller.activate(surface,{{opener}}); opener.isConnected=false; controller.deactivate();
process.stdout.write(JSON.stringify({{focuses:opener.focuses}}));
"""
assert run_node(script) == {"focuses": 0}
def test_controls_inside_collapsed_sections_are_excluded_from_tab_order():
script = f"""
const createModal=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const visible={{disabled:false,hidden:false,isConnected:true,focuses:0,matches(){{return true;}},getClientRects(){{return [{{}}];}},focus(){{this.focuses+=1;}}}};
const collapsed={{...visible,getClientRects(){{return [];}}}};
const surface={{querySelectorAll(){{return [visible,collapsed];}}}};
const document={{activeElement:visible,addEventListener(name,fn){{listeners[name]=fn;}}}};
const controller=createModal({{document,backgrounds:[]}}); controller.activate(surface);
let prevented=0; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{prevented,focuses:visible.focuses}}));
"""
assert run_node(script) == {"prevented": 1, "focuses": 2}
def test_search_modal_is_packaged_with_accessible_semantics():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'id="cmd-palette" role="dialog" aria-modal="true"' in html
assert '<script src="static/mobile-search-modal.js"></script>' in html
assert '"static/mobile-search-modal.js"' in bundle
assert "createMobileSearchModal({" in dashboard
assert "searchModal.transition(qs('#search-preview')" in dashboard