import json import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MENTIONS = ROOT / "frontend" / "mention-composer.js" def run_node(script: str) -> dict: completed = subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True, ) return json.loads(completed.stdout) def test_active_mention_replaces_only_the_token_at_the_caret(): script = f""" const mentions=require({json.dumps(str(MENTIONS))}); const text='Thanks @al for this; keep @casey informed'; const caret=text.indexOf(' for'); const active=mentions.activeMention(text, caret); const inserted=mentions.insertMention(text, active, 'alex'); process.stdout.write(JSON.stringify({{active,inserted}})); """ result = run_node(script) assert result["active"] == {"start": 7, "end": 10, "query": "al"} assert result["inserted"] == { "value": "Thanks @alex for this; keep @casey informed", "caret": 13, } def test_controller_ignores_stale_results_and_keyboard_selects_a_teammate(): script = f""" const mentions=require({json.dumps(str(MENTIONS))}); class Element {{ constructor() {{ this.listeners={{}}; this.children=[]; this.attrs={{}}; this.dataset={{}}; this.hidden=true; this.value=''; this.selectionStart=0; this.selectionEnd=0; this.textContent=''; }} addEventListener(type, fn) {{ (this.listeners[type] ||= []).push(fn); }} dispatch(type, extra={{}}) {{ const event={{preventDefault(){{ this.prevented=true; }}, ...extra}}; (this.listeners[type] || []).forEach(fn=>fn(event)); return event; }} replaceChildren(...children) {{ this.children=children; }} appendChild(child) {{ this.children.push(child); }} setAttribute(name, value) {{ this.attrs[name]=String(value); }} removeAttribute(name) {{ delete this.attrs[name]; }} setSelectionRange(start, end) {{ this.selectionStart=start; this.selectionEnd=end; }} focus() {{ this.focused=true; }} }} const textarea=new Element(), listbox=new Element(), status=new Element(); const pending=[]; const controller=mentions.create({{ textarea,listbox,status,getRepository:()=> 'stackchain/api', loadCandidates:(repository, query)=>new Promise(resolve=>pending.push({{repository,query,resolve}})), setTimer:fn=>{{ fn(); return 1; }}, clearTimer:()=>{{}}, createOption:()=>new Element(), }}); controller.start(); (async()=>{{ textarea.value='Ping @al'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input'); textarea.value='Ping @alex'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input'); pending[1].resolve([{{login:'alex',name:'Alexander'}}]); await Promise.resolve(); await Promise.resolve(); pending[0].resolve([{{login:'alice',name:'Alice'}}]); await Promise.resolve(); await Promise.resolve(); const before={{queries:pending.map(item=>item.query), options:listbox.children.map(item=>item.dataset.login), expanded:textarea.attrs['aria-expanded']}}; textarea.dispatch('keydown', {{key:'ArrowDown'}}); const enter=textarea.dispatch('keydown', {{key:'Enter'}}); process.stdout.write(JSON.stringify({{before,value:textarea.value,caret:textarea.selectionStart,focused:textarea.focused,prevented:enter.prevented,hidden:listbox.hidden}})); }})().catch(error=>{{ console.error(error); process.exit(1); }}); """ result = run_node(script) assert result["before"] == { "queries": ["al", "alex"], "options": ["alex"], "expanded": "true", } assert result["value"] == "Ping @alex " assert result["caret"] == 11 assert result["focused"] is True assert result["prevented"] is True assert result["hidden"] is True