feat: add voice-to-draft mobile capture (Closes #929)
This commit is contained in:
1 parent
9adfcd01b6
commit
3b4bb618dd
9 files changed
+346
-4
No files matched your search
@@ -24,7 +24,7 @@ from fake_gitea import FakeGiteaServer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TITLE = "Offline artifact journey 863"
|
||||
TITLE = "Offline artifact journey 863."
|
||||
BODY = "Captured on a phone, retained offline, delivered exactly once."
|
||||
ACCESS_TOKEN = "artifact-browser-access-token-863"
|
||||
|
||||
@@ -148,6 +148,14 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
service_workers="allow",
|
||||
ignore_https_errors=True,
|
||||
)
|
||||
context.add_init_script("""
|
||||
class DeterministicSpeechRecognition {
|
||||
start() { window.__voiceRecognition = this; }
|
||||
stop() { if (this.onend) this.onend(); }
|
||||
abort() { if (this.onend) this.onend(); }
|
||||
}
|
||||
window.SpeechRecognition = DeterministicSpeechRecognition;
|
||||
""")
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
@@ -178,8 +186,18 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
|
||||
|
||||
new_action.click()
|
||||
page.locator("#create-issue-title").fill(TITLE)
|
||||
page.locator("#create-issue-body").fill(BODY)
|
||||
expect(page.locator("#voice-issue-capture")).to_be_visible()
|
||||
page.locator("#start-voice-issue-capture").click()
|
||||
page.evaluate("""([title, body]) => {
|
||||
const result = [{ transcript: title + ' ' + body }];
|
||||
result.isFinal = true;
|
||||
window.__voiceRecognition.onresult({ results: [result] });
|
||||
}""", [TITLE, BODY])
|
||||
expect(page.locator("#voice-issue-review")).to_be_visible()
|
||||
expect(page.locator("#append-voice-issue-transcript")).to_have_text("Use transcript")
|
||||
page.locator("#append-voice-issue-transcript").click()
|
||||
expect(page.locator("#create-issue-title")).to_have_value(TITLE)
|
||||
expect(page.locator("#create-issue-body")).to_have_value(BODY)
|
||||
page.locator("#file-new-issue").click()
|
||||
expect(page.locator("#create-issue-filing")).to_be_visible()
|
||||
page.locator("#create-issue-repository").select_option("acme/mobile")
|
||||
|
||||
@@ -973,6 +973,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/issue-filing-review.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/checklist-conflict.js",
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
|
||||
VOICE_CAPTURE = Path(__file__).parents[1] / "frontend" / "voice-issue-capture.js"
|
||||
FRONTEND = VOICE_CAPTURE.parent
|
||||
|
||||
|
||||
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_transcript_maps_first_sentence_to_title_and_remainder_to_note_with_field_limits():
|
||||
script = f"""
|
||||
const {{mapVoiceTranscript}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
const mapped = mapVoiceTranscript(' Lift outage on level four. Elevator B is trapped. ');
|
||||
const bounded = mapVoiceTranscript('x'.repeat(300) + '. ' + 'y'.repeat(10100));
|
||||
process.stdout.write(JSON.stringify({{mapped, titleLength:bounded.title.length, bodyLength:bounded.body.length}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"mapped": {
|
||||
"title": "Lift outage on level four.",
|
||||
"body": "Elevator B is trapped.",
|
||||
},
|
||||
"titleLength": 255,
|
||||
"bodyLength": 10000,
|
||||
}
|
||||
|
||||
|
||||
def test_explicit_tap_starts_recognition_and_final_transcript_waits_for_review():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = 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?.({{currentTarget:this}}); }}
|
||||
dispatchEvent() {{}}
|
||||
}}
|
||||
let instance;
|
||||
class Recognition {{
|
||||
constructor() {{ instance=this; this.started=0; }}
|
||||
start() {{ this.started += 1; }}
|
||||
stop() {{}}
|
||||
abort() {{}}
|
||||
}}
|
||||
const elements = Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.review.hidden=true;
|
||||
const controller = createVoiceIssueCapture({{Recognition,elements}});
|
||||
const before = {{started:Boolean(instance), rootHidden:elements.root.hidden}};
|
||||
elements.start.click();
|
||||
instance.onresult({{results:[Object.assign([{{transcript:'Lift outage. Elevator B is trapped.'}}], {{isFinal:true}})]}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
supported:controller.supported, before, started:instance.started,
|
||||
listening:elements.status.textContent, transcript:elements.transcript.value,
|
||||
reviewHidden:elements.review.hidden, title:elements.title.value, body:elements.body.value,
|
||||
appendLabel:elements.append.textContent, replaceHidden:elements.replace.hidden
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"supported": True,
|
||||
"before": {"started": False, "rootHidden": False},
|
||||
"started": 1,
|
||||
"listening": "Transcript ready. Review it before using it.",
|
||||
"transcript": "Lift outage. Elevator B is trapped.",
|
||||
"reviewHidden": False,
|
||||
"title": "",
|
||||
"body": "",
|
||||
"appendLabel": "Use transcript",
|
||||
"replaceHidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_review_requires_explicit_append_or_replace_when_typed_content_exists():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = 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?.({{currentTarget:this}}); }}
|
||||
dispatchEvent(event) {{ this.events.push(event.type); }}
|
||||
}}
|
||||
function setup() {{
|
||||
let instance;
|
||||
class Recognition {{
|
||||
constructor() {{ instance=this; }} start() {{}} stop() {{}} abort() {{}}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.title.value='Existing title'; elements.body.value='Existing note'; elements.review.hidden=true;
|
||||
createVoiceIssueCapture({{Recognition,elements, createEvent:name=>({{type:name}})}});
|
||||
elements.start.click();
|
||||
instance.onresult({{results:[Object.assign([{{transcript:'Lift outage. Elevator B is trapped.'}}], {{isFinal:true}})]}});
|
||||
return elements;
|
||||
}}
|
||||
const appended=setup(); appended.append.click();
|
||||
const replaced=setup(); replaced.replace.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
appended:{{title:appended.title.value,body:appended.body.value,events:[appended.title.events,appended.body.events]}},
|
||||
replaced:{{title:replaced.title.value,body:replaced.body.value,events:[replaced.title.events,replaced.body.events]}}
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"appended": {
|
||||
"title": "Existing title",
|
||||
"body": "Existing note\n\nLift outage.\n\nElevator B is trapped.",
|
||||
"events": [["input"], ["input"]],
|
||||
},
|
||||
"replaced": {
|
||||
"title": "Lift outage.",
|
||||
"body": "Elevator B is trapped.",
|
||||
"events": [["input"], ["input"]],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_unsupported_browser_keeps_keyboard_flow_and_exposes_no_voice_control():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
const element=()=>({{hidden:false,textContent:'',addEventListener(){{}}}});
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,element()])
|
||||
);
|
||||
const controller=createVoiceIssueCapture({{Recognition:null,elements}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
supported:controller.supported, hidden:elements.root.hidden, guidance:elements.status.textContent
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"supported": False,
|
||||
"hidden": True,
|
||||
"guidance": "Voice capture is unavailable; type the title and note instead.",
|
||||
}
|
||||
|
||||
|
||||
def test_stop_error_and_sheet_close_release_recognition_without_losing_transcript():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = 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?.({{currentTarget:this}}); }} dispatchEvent() {{}}
|
||||
}}
|
||||
let instance;
|
||||
class Recognition {{
|
||||
constructor() {{ instance=this; this.stops=0; this.aborts=0; }}
|
||||
start() {{}} stop() {{ this.stops+=1; }} abort() {{ this.aborts+=1; }}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
const controller=createVoiceIssueCapture({{Recognition,elements}});
|
||||
elements.start.click();
|
||||
instance.onresult({{results:[Object.assign([{{transcript:'Partial final transcript.'}}], {{isFinal:true}})]}});
|
||||
elements.stop.click();
|
||||
const afterStop=elements.status.textContent;
|
||||
instance.onerror({{error:'not-allowed'}});
|
||||
const afterError=elements.status.textContent;
|
||||
controller.cancel();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
stops:instance.stops, aborts:instance.aborts, afterStop, afterError,
|
||||
transcript:elements.transcript.value, reviewHidden:elements.review.hidden
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"stops": 1,
|
||||
"aborts": 1,
|
||||
"afterStop": "Finishing transcript…",
|
||||
"afterError": "Microphone permission was denied. Your draft and transcript are unchanged.",
|
||||
"transcript": "Partial final transcript.",
|
||||
"reviewHidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_new_sheet_ships_accessible_voice_review_inside_issue_capture_feature():
|
||||
build = build_frontend(FRONTEND)
|
||||
html = build.dashboard_html
|
||||
capture_bundle = build.feature_bundles["issue-capture"].runtime_bytes
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="voice-issue-capture"' in html
|
||||
assert 'id="start-voice-issue-capture"' in html
|
||||
assert 'id="stop-voice-issue-capture"' in html
|
||||
assert 'id="voice-issue-transcript"' in html
|
||||
assert 'id="voice-issue-status" class="small" aria-live="polite"' in html
|
||||
assert 'id="append-voice-issue-transcript"' in html
|
||||
assert 'id="replace-with-voice-issue-transcript"' in html
|
||||
assert b"function createVoiceIssueCapture" in capture_bundle
|
||||
assert b"function createVoiceIssueCapture" not in build.runtime_bytes
|
||||
assert "window.SpeechRecognition || window.webkitSpeechRecognition" in dashboard
|
||||
assert "voiceIssueCapture.cancel();" in dashboard
|
||||
Reference in new issue
Block a user