feat: recover interrupted mobile voice transcripts (Closes #931)
This commit is contained in:
1 parent
47220929bf
commit
f8c2c080f9
12 files changed
+334
-14
No files matched your search
@@ -349,7 +349,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedAtIdle"] == []
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -375,7 +375,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -506,7 +506,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -529,7 +529,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -625,7 +625,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
@@ -30,6 +30,7 @@ process.stdout.write(JSON.stringify(databases));
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
||||
@@ -412,6 +412,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
@@ -504,6 +505,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
@@ -973,6 +975,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-transcript-store.js",
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
@@ -1104,6 +1107,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
@@ -6,6 +6,7 @@ from src.frontend_bundle import build_frontend
|
||||
|
||||
|
||||
VOICE_CAPTURE = Path(__file__).parents[1] / "frontend" / "voice-issue-capture.js"
|
||||
VOICE_STORE = Path(__file__).parents[1] / "frontend" / "voice-transcript-store.js"
|
||||
FRONTEND = VOICE_CAPTURE.parent
|
||||
|
||||
|
||||
@@ -79,6 +80,159 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_final_transcript_is_checkpointed_for_the_confirmed_account():
|
||||
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 recognition;
|
||||
class Recognition {{
|
||||
constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.review.hidden=true;
|
||||
const saved=[];
|
||||
const transcriptStore={{
|
||||
async load() {{ return ''; }},
|
||||
async save(login, transcript) {{ saved.push({{login, transcript}}); }},
|
||||
async clear() {{}},
|
||||
}};
|
||||
(async () => {{
|
||||
const controller=createVoiceIssueCapture({{
|
||||
Recognition,elements,transcriptStore,getLogin:()=> 'timmy'
|
||||
}});
|
||||
await controller.ready;
|
||||
elements.start.click();
|
||||
recognition.onresult({{results:[Object.assign([{{transcript:'Lift outage.'}}], {{isFinal:true}})]}});
|
||||
await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{saved, transcript:elements.transcript.value}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"saved": [{"login": "timmy", "transcript": "Lift outage."}],
|
||||
"transcript": "Lift outage.",
|
||||
}
|
||||
|
||||
|
||||
def test_pending_transcript_recovers_only_for_the_confirmed_account():
|
||||
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; }}
|
||||
dispatchEvent() {{}}
|
||||
}}
|
||||
class Recognition {{ start() {{}} stop() {{}} abort() {{}} }}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.review.hidden=true;
|
||||
const loads=[];
|
||||
(async () => {{
|
||||
const controller=createVoiceIssueCapture({{
|
||||
Recognition,elements,getLogin:()=> 'timmy',
|
||||
transcriptStore:{{
|
||||
async load(login) {{ loads.push(login); return 'Recovered lift outage.'; }},
|
||||
async save() {{}}, async clear() {{}},
|
||||
}},
|
||||
}});
|
||||
await controller.ready;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
loads, transcript:elements.transcript.value, reviewHidden:elements.review.hidden,
|
||||
status:elements.status.textContent
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"loads": ["timmy"],
|
||||
"transcript": "Recovered lift outage.",
|
||||
"reviewHidden": False,
|
||||
"status": "Recovered transcript. Review it before using or discarding it.",
|
||||
}
|
||||
|
||||
|
||||
def test_multiple_final_segments_accumulate_in_one_bounded_checkpoint():
|
||||
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?.(); }} dispatchEvent() {{}}
|
||||
}}
|
||||
let recognition;
|
||||
class Recognition {{ constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}} }}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
const saved=[];
|
||||
(async () => {{
|
||||
const controller=createVoiceIssueCapture({{Recognition,elements,getLogin:()=> 'timmy',transcriptStore:{{
|
||||
async load() {{ return ''; }}, async save(login,text) {{ saved.push(text); }}, async clear() {{}},
|
||||
}}}});
|
||||
await controller.ready; elements.start.click();
|
||||
recognition.onresult({{results:[Object.assign([{{transcript:'Lift outage.'}}],{{isFinal:true}})]}});
|
||||
recognition.onresult({{results:[Object.assign([{{transcript:'Elevator B is trapped.'}}],{{isFinal:true}})]}});
|
||||
await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{transcript:elements.transcript.value,saved}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"transcript": "Lift outage. Elevator B is trapped.",
|
||||
"saved": ["Lift outage.", "Lift outage. Elevator B is trapped."],
|
||||
}
|
||||
|
||||
|
||||
def test_apply_and_discard_each_clear_the_consumed_checkpoint():
|
||||
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?.(); }} dispatchEvent() {{}}
|
||||
}}
|
||||
class Recognition {{ start(){{}} stop(){{}} abort(){{}} }}
|
||||
async function setup() {{
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
const cleared=[];
|
||||
const controller=createVoiceIssueCapture({{Recognition,elements,getLogin:()=> 'timmy',transcriptStore:{{
|
||||
async load() {{ return 'Recovered lift outage.'; }}, async save() {{}},
|
||||
async clear(login) {{ cleared.push(login); }},
|
||||
}}}});
|
||||
await controller.ready;
|
||||
return {{elements,cleared}};
|
||||
}}
|
||||
(async () => {{
|
||||
const applied=await setup(); applied.elements.append.click(); await Promise.resolve();
|
||||
const discarded=await setup(); discarded.elements.discard.click(); await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
applied:{{cleared:applied.cleared,reviewHidden:applied.elements.review.hidden,title:applied.elements.title.value}},
|
||||
discarded:{{cleared:discarded.cleared,reviewHidden:discarded.elements.review.hidden,transcript:discarded.elements.transcript.value,status:discarded.elements.status.textContent}}
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"applied": {
|
||||
"cleared": ["timmy"],
|
||||
"reviewHidden": True,
|
||||
"title": "Recovered lift outage.",
|
||||
},
|
||||
"discarded": {
|
||||
"cleared": ["timmy"],
|
||||
"reviewHidden": True,
|
||||
"transcript": "",
|
||||
"status": "Recovered transcript discarded.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_review_requires_explicit_append_or_replace_when_typed_content_exists():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
@@ -181,6 +335,37 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_private_store_isolates_bounded_transcripts_by_normalized_account():
|
||||
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 ', ' Lift outage. ' + 'x'.repeat(11000));
|
||||
await store.save('alexander', 'Private second account');
|
||||
const before={{timmy:await store.load('timmy'),alexander:await store.load('alexander')}};
|
||||
await store.clear('TIMMY');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
before,after:await store.load('timmy'),keys:[...records.keys()],length:before.timmy.length
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"before": {
|
||||
"timmy": "Lift outage. " + "x" * 10242,
|
||||
"alexander": "Private second account",
|
||||
},
|
||||
"after": "",
|
||||
"keys": ["alexander"],
|
||||
"length": 10255,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_new_sheet_ships_accessible_voice_review_inside_issue_capture_feature():
|
||||
build = build_frontend(FRONTEND)
|
||||
html = build.dashboard_html
|
||||
@@ -194,7 +379,15 @@ def test_mobile_new_sheet_ships_accessible_voice_review_inside_issue_capture_fea
|
||||
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 'id="discard-voice-issue-transcript"' in html
|
||||
assert b"function createVoiceIssueCapture" in capture_bundle
|
||||
assert b"function createVoiceTranscriptStore" in capture_bundle
|
||||
assert b"function createVoiceIssueCapture" not in build.runtime_bytes
|
||||
assert "window.SpeechRecognition || window.webkitSpeechRecognition" in dashboard
|
||||
assert "transcriptStore:createVoiceTranscriptStore()" in dashboard
|
||||
assert "getLogin:()=>confirmedOwnerLogin" in dashboard
|
||||
assert "voiceIssueCapture.cancel();" in dashboard
|
||||
registry = run_node(
|
||||
f"process.stdout.write(JSON.stringify(require({json.dumps(str(FRONTEND / 'private-data-registry.js'))})))"
|
||||
)
|
||||
assert "stackchain-voice-transcripts-v1" in registry
|
||||
Reference in new issue
Block a user