309 lines
14 KiB
Python
309 lines
14 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_today_progress_dictation_uses_its_composer_boundary_and_copy():
|
|
script = f"""
|
|
const {{mountVoiceConversation}} = require({json.dumps(str(VOICE_CAPTURE))});
|
|
const requested=[]; const nodes=new Map();
|
|
const qs=selector=>{{requested.push(selector);if(!nodes.has(selector))nodes.set(selector,{{selector}});return nodes.get(selector);}};
|
|
const created=[];
|
|
const result=mountVoiceConversation({{
|
|
kind:'today-progress',draftSelector:'#today-progress-body',qs,Recognition:function Recognition(){{}},
|
|
transcriptStore:{{name:'store'}},getLogin:()=> 'timmy',
|
|
createCapture:options=>{{created.push(options);return {{supported:true}};}},
|
|
}});
|
|
process.stdout.write(JSON.stringify({{result,requested,elements:Object.fromEntries(Object.entries(created[0].elements).map(([key,value])=>[key,value.selector]))}}));
|
|
"""
|
|
assert run_node(script) == {
|
|
"result": {"supported": True},
|
|
"requested": [
|
|
"#voice-today-progress", "#start-voice-today-progress", "#stop-voice-today-progress",
|
|
"#voice-today-progress-review", "#voice-today-progress-transcript",
|
|
"#append-voice-today-progress", "#replace-with-voice-today-progress",
|
|
"#discard-voice-today-progress", "#voice-today-progress-status", "#today-progress-body",
|
|
],
|
|
"elements": {
|
|
"root": "#voice-today-progress", "start": "#start-voice-today-progress",
|
|
"stop": "#stop-voice-today-progress", "review": "#voice-today-progress-review",
|
|
"transcript": "#voice-today-progress-transcript", "append": "#append-voice-today-progress",
|
|
"replace": "#replace-with-voice-today-progress", "discard": "#discard-voice-today-progress",
|
|
"status": "#voice-today-progress-status", "draft": "#today-progress-body",
|
|
},
|
|
}
|
|
|
|
|
|
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; }} start() {{}} stop() {{}} abort() {{}} }}
|
|
const elements=Object.fromEntries(
|
|
['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()])
|
|
);
|
|
elements.draft.value='Typed';
|
|
elements.draft.maxLength=2000;
|
|
elements.root.dataset={{draftLabel:'progress update'}};
|
|
const saved=[];
|
|
const store={{load:async()=>'',save:async(_login,text)=>saved.push(text),clear:async()=>{{}}}};
|
|
(async()=>{{
|
|
const controller=createVoiceConversationCapture({{
|
|
Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy',createEvent:name=>({{type:name}})
|
|
}});
|
|
await controller.open('issue:stackchain/dashboard:1058:');
|
|
elements.start.click();
|
|
recognition.onresult({{results:[Object.assign([{{transcript:'x'.repeat(2500)}}],{{isFinal:true}})]}});
|
|
const reviewed={{length:elements.transcript.value.length,status:elements.status.textContent,savedLength:saved[0].length}};
|
|
elements.replace.click();
|
|
process.stdout.write(JSON.stringify({{reviewed,draftLength:elements.draft.value.length,status:elements.status.textContent}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
assert run_node(script) == {
|
|
"reviewed": {
|
|
"length": 2000,
|
|
"savedLength": 2000,
|
|
"status": "Transcript ready. Review it before using it.",
|
|
},
|
|
"draftLength": 2000,
|
|
"status": "Transcript added. Review the progress update before sending.",
|
|
}
|
|
|
|
|
|
def test_unsupported_today_dictation_keeps_progress_entry_available_with_truthful_copy():
|
|
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; }} dispatchEvent() {{}}
|
|
}}
|
|
const elements=Object.fromEntries(
|
|
['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()])
|
|
);
|
|
const controller=createVoiceConversationCapture({{Recognition:null,elements,draftLabel:'progress update'}});
|
|
process.stdout.write(JSON.stringify({{
|
|
supported:controller.supported,voiceHidden:elements.root.hidden,draftHidden:elements.draft.hidden,
|
|
status:elements.status.textContent
|
|
}}));
|
|
"""
|
|
assert run_node(script) == {
|
|
"supported": False,
|
|
"voiceHidden": True,
|
|
"draftHidden": False,
|
|
"status": "Voice capture is unavailable; type the progress update instead.",
|
|
}
|
|
|
|
|
|
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 "['issue-comment', '#issue-comment']" in dashboard
|
|
assert "['pull-comment', '#pull-comment']" in dashboard
|
|
assert "['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
|
|
|
|
|
|
def test_mobile_search_reply_ships_target_scoped_voice_review_and_cancels_on_close():
|
|
html = (FRONTEND / "index.html").read_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
dashboard = (FRONTEND / "dashboard.js").read_text()
|
|
|
|
for control in (
|
|
"voice-search-reply",
|
|
"start-voice-search-reply",
|
|
"stop-voice-search-reply",
|
|
"voice-search-reply-review",
|
|
"voice-search-reply-transcript",
|
|
"append-voice-search-reply",
|
|
"replace-with-voice-search-reply",
|
|
"discard-voice-search-reply",
|
|
"voice-search-reply-status",
|
|
):
|
|
assert f'id="{control}"' in html
|
|
|
|
assert 'for="voice-search-reply-transcript"' in html
|
|
assert "['search-reply', '#search-preview-reply']" in dashboard
|
|
assert "searchVoiceReply.open(searchConversationVoiceTarget(state.item))" in dashboard
|
|
assert "searchVoiceReply.cancel();" in dashboard
|
|
assert ".search-preview-reply .voice-conversation-controls button" in css
|
|
assert "min-height:44px" in css
|