stackchain-dashboard/tests/test_voice_conversation_capture.py
timmy 0e13872311
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
feat: dictate mobile conversation replies (Closes #951)
2026-08-16 08:35:34 +00:00

188 lines
8.0 KiB
Python

import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).parents[1] / "frontend"
VOICE_CAPTURE = FRONTEND / "voice-conversation-capture.js"
VOICE_STORE = FRONTEND / "voice-transcript-store.js"
def run_node(script: str):
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_dictated_reply_waits_for_review_then_appends_to_the_current_conversation():
script = f"""
const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; this.events=[]; }}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
click() {{ this.listeners.click?.(); }}
dispatchEvent(event) {{ this.events.push(event.type); }}
}}
let recognition;
class Recognition {{
constructor() {{ recognition=this; this.aborts=0; }}
start() {{}} stop() {{}} abort() {{ this.aborts += 1; }}
}}
const elements=Object.fromEntries(
['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()])
);
elements.review.hidden=true;
elements.draft.value='Typed opening';
const saved=[]; const cleared=[];
const store={{
async load(login, target) {{ return ''; }},
async save(login, transcript, target) {{ saved.push({{login, transcript, target}}); }},
async clear(login, target) {{ cleared.push({{login, target}}); }},
}};
(async()=>{{
const controller=createVoiceConversationCapture({{
Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy',createEvent:name=>({{type:name}})
}});
await controller.open('issue:stackchain/stackchain-dashboard#951');
elements.start.click();
const first=Object.assign([{{transcript:'Please test on mobile.'}}],{{isFinal:true}});
recognition.onresult({{resultIndex:0,results:[first]}});
const before={{draft:elements.draft.value,reviewHidden:elements.review.hidden,transcript:elements.transcript.value}};
elements.append.click(); await Promise.resolve();
process.stdout.write(JSON.stringify({{
before,draft:elements.draft.value,events:elements.draft.events,saved,cleared,
reviewHidden:elements.review.hidden,status:elements.status.textContent
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"before": {
"draft": "Typed opening",
"reviewHidden": False,
"transcript": "Please test on mobile.",
},
"draft": "Typed opening\n\nPlease test on mobile.",
"events": ["input"],
"saved": [
{
"login": "timmy",
"transcript": "Please test on mobile.",
"target": "issue:stackchain/stackchain-dashboard#951",
}
],
"cleared": [
{"login": "timmy", "target": "issue:stackchain/stackchain-dashboard#951"}
],
"reviewHidden": True,
"status": "Transcript added. Review the reply before sending.",
}
def test_private_store_isolates_conversation_transcripts_by_account_and_target():
script = f"""
const createVoiceTranscriptStore=require({json.dumps(str(VOICE_STORE))});
const records=new Map();
const transaction=async work=>work({{
get:async key=>records.get(key),
put:async value=>records.set(value.login,{{...value}}),
delete:async key=>records.delete(key),
}});
(async()=>{{
const store=createVoiceTranscriptStore({{transaction}});
await store.save('timmy','Issue reply','issue:org/repo#1');
await store.save('timmy','Pull reply','pull:org/repo#2');
await store.save('alexander','Other account','issue:org/repo#1');
const before={{
issue:await store.load('TIMMY','issue:org/repo#1'),
pull:await store.load('timmy','pull:org/repo#2'),
other:await store.load('alexander','issue:org/repo#1'),
}};
await store.clear('timmy','issue:org/repo#1');
process.stdout.write(JSON.stringify({{before,cleared:await store.load('timmy','issue:org/repo#1')}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"before": {"issue": "Issue reply", "pull": "Pull reply", "other": "Other account"},
"cleared": "",
}
def test_switching_conversations_aborts_listening_and_recovers_only_the_new_target():
script = f"""
const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}}; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
}}
const instances=[];
class Recognition {{
constructor() {{ this.aborts=0; instances.push(this); }} start() {{}} stop() {{}} abort() {{ this.aborts+=1; }}
}}
const elements=Object.fromEntries(
['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()])
);
const loads=[]; const saved=[];
const store={{
async load(login,target) {{ loads.push(target); return target.endsWith('#2') ? 'Recovered pull reply.' : ''; }},
async save(login,text,target) {{ saved.push({{text,target}}); }}, async clear() {{}},
}};
(async()=>{{
const controller=createVoiceConversationCapture({{Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy'}});
await controller.open('issue:org/repo#1'); elements.start.click();
await controller.open('pull:org/repo#2');
instances[0].onresult({{results:[Object.assign([{{transcript:'Stale issue words.'}}],{{isFinal:true}})]}});
instances[0].onerror({{error:'not-allowed'}});
instances[0].onend();
await Promise.resolve();
process.stdout.write(JSON.stringify({{
loads,saved,aborts:instances[0].aborts,transcript:elements.transcript.value,
reviewHidden:elements.review.hidden,status:elements.status.textContent,
startHidden:elements.start.hidden,stopHidden:elements.stop.hidden
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"loads": ["issue:org/repo#1", "pull:org/repo#2"],
"saved": [],
"aborts": 1,
"transcript": "Recovered pull reply.",
"reviewHidden": False,
"status": "Recovered transcript. Review it before using or discarding it.",
"startHidden": False,
"stopHidden": True,
}
def test_all_mobile_conversation_composers_ship_accessible_voice_review_controls():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
bundle = (Path(__file__).parents[1] / "src" / "frontend_bundle.py").read_text()
for kind, draft in (
("issue-comment", "issue-comment"),
("pull-comment", "pull-comment"),
("update-reply", "update-reply"),
):
assert f'id="voice-{kind}"' in html
assert f'id="start-voice-{kind}"' in html
assert f'id="voice-{kind}-transcript"' in html
assert f'for="voice-{kind}-transcript"' in html
assert f'id="append-voice-{kind}"' in html
assert f'id="replace-with-voice-{kind}"' in html
assert f'id="discard-voice-{kind}"' in html
assert "mountConversationVoice('issue-comment', '#issue-comment')" in dashboard
assert "mountConversationVoice('pull-comment', '#pull-comment')" in dashboard
assert "mountConversationVoice('update-reply', '#update-reply')" in dashboard
assert ".voice-conversation-controls button" in css
assert "min-height:44px" in css
assert '"static/voice-conversation-capture.js"' in bundle
assert "issueVoiceReply.open(conversationVoiceTarget('issue', item))" in dashboard
assert "pullVoiceReply.open(conversationVoiceTarget('pull', item))" in dashboard
assert "updateVoiceReply.open('update:' + item.notification_id)" in dashboard
assert "issueVoiceReply.cancel();" in dashboard
assert "pullVoiceReply.cancel();" in dashboard
assert "updateVoiceReply.cancel();" in dashboard