279 lines
14 KiB
Python
279 lines
14 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-insights.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_mobile_insights_opens_as_addressable_modal_and_hides_home_chrome():
|
|
script = f"""
|
|
const createInsights = require({json.dumps(str(CONTROLLER))});
|
|
const listeners = {{}};
|
|
const element = () => ({{
|
|
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
|
|
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
|
|
removeAttribute(name) {{ delete this.attributes[name]; }},
|
|
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
|
|
focus() {{ this.focuses += 1; }},
|
|
}});
|
|
const root = element(); const launcher = element(); const closeButton = element();
|
|
const dock = element(); const hud = element(); const home = element(); const header = element();
|
|
const menu = {{open:true}};
|
|
const location = {{pathname:'/dashboard/', search:'', hash:'#/my-work/today'}};
|
|
const history = {{state:null, pushes:[], pushState(state,unused,url) {{
|
|
this.state=state; this.pushes.push(url); location.hash=url.slice(url.indexOf('#'));
|
|
}}, replaceState() {{}}, back() {{}}}};
|
|
const controller = createInsights({{
|
|
root, launcher, closeButton, dock, hud, backgrounds:[header,home], menu, history, location,
|
|
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
|
|
mediaQuery:{{matches:true, addEventListener() {{}}}},
|
|
}});
|
|
controller.start();
|
|
launcher.listeners.click();
|
|
process.stdout.write(JSON.stringify({{
|
|
hash:location.hash, pushes:history.pushes, open:root.attributes['data-mobile-open'],
|
|
role:root.attributes.role, modal:root.attributes['aria-modal'], rootInert:root.inert,
|
|
homeInert:home.inert, headerInert:header.inert, dockHidden:dock.hidden,
|
|
hudHidden:hud.attributes['data-overlay-hidden'], closeFocuses:closeButton.focuses,
|
|
menuOpen:menu.open,
|
|
}}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"hash": "#/insights",
|
|
"pushes": ["/dashboard/#/insights"],
|
|
"open": "true",
|
|
"role": "dialog",
|
|
"modal": "true",
|
|
"rootInert": False,
|
|
"homeInert": True,
|
|
"headerInert": True,
|
|
"dockHidden": True,
|
|
"hudHidden": "true",
|
|
"closeFocuses": 1,
|
|
"menuOpen": False,
|
|
}
|
|
|
|
|
|
def test_direct_insights_route_closes_locally_and_restores_launcher_focus():
|
|
script = f"""
|
|
const createInsights = require({json.dumps(str(CONTROLLER))});
|
|
const listeners = {{}};
|
|
const element = () => ({{
|
|
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
|
|
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
|
|
removeAttribute(name) {{ delete this.attributes[name]; }},
|
|
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
|
|
focus() {{ this.focuses += 1; }},
|
|
}});
|
|
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), home=element();
|
|
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
|
|
const history={{state:null, backs:0, replacements:[], pushState() {{}}, back() {{ this.backs += 1; }},
|
|
replaceState(state,unused,url) {{ this.state=state; this.replacements.push(url); location.hash=url.slice(url.indexOf('#')); }}
|
|
}};
|
|
const controller=createInsights({{
|
|
root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,
|
|
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
|
|
mediaQuery:{{matches:true,addEventListener() {{}}}},
|
|
}});
|
|
controller.start();
|
|
closeButton.listeners.click();
|
|
process.stdout.write(JSON.stringify({{
|
|
current:controller.current(), hash:location.hash, backs:history.backs,
|
|
replacements:history.replacements, rootInert:root.inert, homeInert:home.inert,
|
|
dockHidden:dock.hidden, hudHidden:hud.attributes['data-overlay-hidden'] || null,
|
|
launcherFocuses:launcher.focuses,
|
|
}}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"current": False,
|
|
"hash": "#/my-work",
|
|
"backs": 0,
|
|
"replacements": ["/dashboard/#/my-work"],
|
|
"rootInert": True,
|
|
"homeInert": False,
|
|
"dockHidden": False,
|
|
"hudHidden": None,
|
|
"launcherFocuses": 1,
|
|
}
|
|
|
|
|
|
def test_browser_back_closes_insights_without_resetting_the_work_route():
|
|
script = f"""
|
|
const createInsights = require({json.dumps(str(CONTROLLER))});
|
|
const listeners={{}};
|
|
const element=()=>({{hidden:false,inert:false,attributes:{{}},focuses:0,listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{this.focuses+=1;}}}});
|
|
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element(),home=element();
|
|
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/update'}};
|
|
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
|
|
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
|
controller.start(); launcher.listeners.click();
|
|
location.hash='#/my-work/update'; history.state=null; listeners.popstate?.({{state:null}});
|
|
process.stdout.write(JSON.stringify({{current:controller.current(),hash:location.hash,launcherFocuses:launcher.focuses,homeInert:home.inert,dockHidden:dock.hidden}}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"current": False,
|
|
"hash": "#/my-work/update",
|
|
"launcherFocuses": 1,
|
|
"homeInert": False,
|
|
"dockHidden": False,
|
|
}
|
|
|
|
|
|
def test_late_inactive_route_event_does_not_release_background_owned_by_next_overlay():
|
|
script = f"""
|
|
const createInsights = require({json.dumps(str(CONTROLLER))});
|
|
const listeners = {{}};
|
|
const element = () => ({{
|
|
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
|
|
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
|
|
removeAttribute(name) {{ delete this.attributes[name]; }},
|
|
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
|
|
focus() {{ this.focuses += 1; }},
|
|
}});
|
|
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), header=element();
|
|
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
|
|
const history={{state:null, pushState() {{}}, back() {{}}, replaceState(state,unused,url) {{
|
|
this.state=state; location.hash=url.slice(url.indexOf('#'));
|
|
}}}};
|
|
const controller=createInsights({{
|
|
root,launcher,closeButton,dock,hud,backgrounds:[header],history,location,
|
|
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
|
|
mediaQuery:{{matches:true,addEventListener() {{}}}},
|
|
}});
|
|
controller.start();
|
|
closeButton.listeners.click();
|
|
header.inert = true;
|
|
listeners.hashchange();
|
|
process.stdout.write(JSON.stringify({{current:controller.current(), headerInert:header.inert}}));
|
|
"""
|
|
|
|
assert run_node(script) == {"current": False, "headerInert": True}
|
|
|
|
|
|
def test_escape_requests_back_navigation_only_while_insights_is_open():
|
|
script = f"""
|
|
const createInsights=require({json.dumps(str(CONTROLLER))});
|
|
const listeners={{}};
|
|
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
|
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element();
|
|
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work'}};
|
|
const history={{state:null,backs:0,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{this.backs+=1;}}}};
|
|
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
|
controller.start(); launcher.listeners.click();
|
|
let prevented=0; listeners.keydown?.({{key:'Escape',preventDefault(){{prevented+=1;}}}}); listeners.keydown?.({{key:'Enter',preventDefault(){{prevented+=1;}}}});
|
|
process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
|
|
"""
|
|
|
|
assert run_node(script) == {"backs": 1, "prevented": 1}
|
|
|
|
|
|
def test_insights_route_owns_the_today_detour_for_open_back_and_reload():
|
|
script = f"""
|
|
const createInsights=require({json.dumps(str(CONTROLLER))});
|
|
const listeners={{}};
|
|
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
|
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
|
|
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
|
|
const calls=[];
|
|
const detour={{beginDetour(reason){{calls.push('begin:'+reason);return {{identity:'issue:r:42:',reason}};}},finishDetour(){{calls.push('finish');return {{identity:'issue:r:42:'}};}}}};
|
|
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
|
|
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
|
controller.start();
|
|
launcher.listeners.click();
|
|
location.hash='#/my-work/today'; history.state=null; listeners.popstate();
|
|
listeners.popstate();
|
|
location.hash='#/insights'; listeners.hashchange();
|
|
process.stdout.write(JSON.stringify({{calls,current:controller.current()}}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"calls": ["begin:insights", "finish", "begin:insights"],
|
|
"current": True,
|
|
}
|
|
|
|
|
|
def test_return_to_today_closes_insights_and_finishes_the_detour_once():
|
|
script = f"""
|
|
const createInsights=require({json.dumps(str(CONTROLLER))});
|
|
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
|
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
|
|
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
|
|
const calls=[];
|
|
const detour={{beginDetour(reason){{calls.push('begin:'+reason);}},finishDetour(){{calls.push('finish');}}}};
|
|
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},back(){{}}}};
|
|
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
|
controller.start(); launcher.listeners.click(); returnButton.listeners.click();
|
|
process.stdout.write(JSON.stringify({{calls,current:controller.current(),hash:location.hash}}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"calls": ["begin:insights", "finish"],
|
|
"current": False,
|
|
"hash": "#/my-work/today",
|
|
}
|
|
|
|
|
|
def test_direct_insights_route_retries_detour_after_identity_restores():
|
|
script = f"""
|
|
const createInsights=require({json.dumps(str(CONTROLLER))});
|
|
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
|
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
|
|
const location={{pathname:'/dashboard/',search:'',hash:'#/insights'}};
|
|
let attempts=0; let retry;
|
|
global.setTimeout=callback=>{{retry=callback;}};
|
|
const detour={{beginDetour(){{attempts+=1;return attempts > 1 ? {{identity:'issue:r:42:'}} : null;}},finishDetour(){{}}}};
|
|
const history={{state:null,replaceState(){{}},pushState(){{}},back(){{}}}};
|
|
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
|
controller.start(); retry();
|
|
process.stdout.write(JSON.stringify({{attempts,current:controller.current()}}));
|
|
"""
|
|
|
|
assert run_node(script) == {"attempts": 2, "current": True}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
|
|
html = await dashboard()
|
|
|
|
assert 'id="open-insights"' in html
|
|
assert 'id="insights-sheet"' in html
|
|
assert 'id="close-insights"' in html
|
|
insights = html[html.index('id="insights-sheet"'):html.index('</section>', html.index('id="insights-sheet"'))]
|
|
assert 'data-today-detour' in insights
|
|
assert 'data-return-from-detour' in insights
|
|
assert "detour:() => timerView" in html
|
|
assert html.index("const timerView = createTodayTimerView({") < html.index("mobileInsights.start();")
|
|
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
|
|
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
|
|
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')
|
|
assert '<script src="static/mobile-insights.js"></script>' in html
|
|
assert "createMobileInsights({" in html
|
|
assert "mediaQuery: window.matchMedia('(max-width: 600px)')" in html
|
|
assert ".insights-shell { display:contents; }" in html
|
|
assert "#open-insights { display:none; }" in html
|
|
assert '.insights-shell[data-mobile-open="true"]' in html
|
|
assert "position:fixed; inset:0;" in html
|
|
assert "overflow-x:hidden" in html
|
|
assert ".insights-header button { min-height:44px;" in html
|
|
|
|
|
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
|
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v150" in worker
|
|
assert "BASE + 'static/mobile-insights.js'" in worker
|