From f8c2c080f9a41b2ff5ae7d236c4988f46175c8c7 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 02:37:54 +0000 Subject: [PATCH] feat: recover interrupted mobile voice transcripts (Closes #931) --- frontend/dashboard.js | 6 +- frontend/index.html | 2 + frontend/private-data-registry.js | 1 + frontend/service-worker.js | 1 + frontend/voice-issue-capture.js | 40 ++++- frontend/voice-transcript-store.js | 87 ++++++++++ src/frontend_bundle.py | 2 +- tests/test_dashboard_session_frontend.py | 10 +- tests/test_private_data_registry.py | 1 + tests/test_private_device_data.py | 1 + tests/test_service_worker.py | 4 + tests/test_voice_issue_capture.py | 193 +++++++++++++++++++++++ 12 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 frontend/voice-transcript-store.js diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a1c509c..7d50dd3 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -748,9 +748,11 @@ root:qs('#voice-issue-capture'), start:qs('#start-voice-issue-capture'), stop:qs('#stop-voice-issue-capture'), review:qs('#voice-issue-review'), transcript:qs('#voice-issue-transcript'), append:qs('#append-voice-issue-transcript'), - replace:qs('#replace-with-voice-issue-transcript'), status:qs('#voice-issue-status'), - title:qs('#create-issue-title'), body:qs('#create-issue-body'), + replace:qs('#replace-with-voice-issue-transcript'), discard:qs('#discard-voice-issue-transcript'), + status:qs('#voice-issue-status'), title:qs('#create-issue-title'), body:qs('#create-issue-body'), }, + transcriptStore:createVoiceTranscriptStore(), + getLogin:()=>confirmedOwnerLogin, }); issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document, () => { saveIssueCaptureDraft(); updateIssueCreateActions(); }); diff --git a/frontend/index.html b/frontend/index.html index d29c810..e9fc4be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -833,6 +833,7 @@
+
@@ -1369,6 +1370,7 @@ + diff --git a/frontend/private-data-registry.js b/frontend/private-data-registry.js index 3ca2b84..1c86653 100644 --- a/frontend/private-data-registry.js +++ b/frontend/private-data-registry.js @@ -3,6 +3,7 @@ 'stackchain-background-outbox-v1', 'stackchain-offline-work-v2', 'stackchain-unfiled-captures-v1', + 'stackchain-voice-transcripts-v1', ]); if (typeof module !== 'undefined' && module.exports) module.exports = databases; else root.stackchainPrivateDatabases = databases; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 605c939..663bc12 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -75,6 +75,7 @@ const SHELL = [ BASE + 'static/issue-filing-review.js', BASE + 'static/issue-sheet.js', BASE + 'static/checklist-conflict.js', + BASE + 'static/voice-transcript-store.js', BASE + 'static/voice-issue-capture.js', BASE + 'static/create-issue-sheet.js', BASE + 'static/create-and-start.js', diff --git a/frontend/voice-issue-capture.js b/frontend/voice-issue-capture.js index ac80384..9265ec7 100644 --- a/frontend/voice-issue-capture.js +++ b/frontend/voice-issue-capture.js @@ -7,22 +7,34 @@ function mapVoiceTranscript(value) { return {title, body}; } -function createVoiceIssueCapture({Recognition, elements, createEvent = name => new Event(name, {bubbles:true})}) { +function createVoiceIssueCapture({ + Recognition, elements, createEvent = name => new Event(name, {bubbles:true}), + transcriptStore = null, getLogin = () => '', +}) { const supported = typeof Recognition === 'function'; + const login = String(getLogin() || '').trim(); + const ready = supported && login && transcriptStore ? Promise.resolve(transcriptStore.load(login)).then(value => { + const recovered = String(value || '').replace(/\s+/g, ' ').trim().slice(0, 10255); + if (recovered) showReview(recovered, {persist:false, recovered:true}); + }) : Promise.resolve(); elements.root.hidden = !supported; if (!supported) { elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.'; - return {supported:false, cancel() {}}; + return {supported:false, ready, cancel() {}}; } let recognition = null; - function showReview(value) { + function showReview(value, {persist = true, recovered = false} = {}) { elements.transcript.value = value; + const activeLogin = String(getLogin() || '').trim(); + if (persist && activeLogin && transcriptStore) void transcriptStore.save(activeLogin, value); const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim()); elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript'; elements.replace.hidden = !hasDraft; elements.review.hidden = false; - elements.status.textContent = 'Transcript ready. Review it before using it.'; + elements.status.textContent = recovered ? + 'Recovered transcript. Review it before using or discarding it.' : + 'Transcript ready. Review it before using it.'; } elements.start.addEventListener('click', () => { @@ -33,7 +45,11 @@ function createVoiceIssueCapture({Recognition, elements, createEvent = name => n const finalText = Array.from(event.results || []) .filter(result => result.isFinal) .map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim(); - if (finalText) showReview(finalText); + if (finalText) { + const accumulated = [elements.transcript.value.trim(), finalText] + .filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 10255); + showReview(accumulated); + } }; recognition.onerror = event => { elements.start.hidden = false; @@ -56,6 +72,11 @@ function createVoiceIssueCapture({Recognition, elements, createEvent = name => n elements.status.textContent = 'Finishing transcript…'; }); + function clearCheckpoint() { + const activeLogin = String(getLogin() || '').trim(); + if (activeLogin && transcriptStore) void transcriptStore.clear(activeLogin); + } + function commit(mode) { const mapped = mapVoiceTranscript(elements.transcript.value); if (mode === 'replace') { @@ -71,13 +92,20 @@ function createVoiceIssueCapture({Recognition, elements, createEvent = name => n } elements.title.dispatchEvent(createEvent('input')); elements.body.dispatchEvent(createEvent('input')); + clearCheckpoint(); elements.review.hidden = true; elements.status.textContent = 'Transcript added. Review the title and note before saving.'; } elements.append.addEventListener('click', () => commit('append')); elements.replace.addEventListener('click', () => commit('replace')); + elements.discard?.addEventListener('click', () => { + clearCheckpoint(); + elements.transcript.value = ''; + elements.review.hidden = true; + elements.status.textContent = 'Recovered transcript discarded.'; + }); - return {supported:true, cancel() { recognition?.abort(); recognition = null; }}; + return {supported:true, ready, cancel() { recognition?.abort(); recognition = null; }}; } if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture}; diff --git a/frontend/voice-transcript-store.js b/frontend/voice-transcript-store.js new file mode 100644 index 0000000..1ad4cc6 --- /dev/null +++ b/frontend/voice-transcript-store.js @@ -0,0 +1,87 @@ +(function (root, factory) { + const createVoiceTranscriptStore = factory(root.indexedDB); + if (typeof module !== 'undefined' && module.exports) module.exports = createVoiceTranscriptStore; + else root.createVoiceTranscriptStore = createVoiceTranscriptStore; +})(typeof globalThis !== 'undefined' ? globalThis : this, function buildFactory(defaultIndexedDB) { + const dbName = 'stackchain-voice-transcripts-v1'; + const STORE_NAME = 'checkpoints'; + const MAX_TRANSCRIPT_LENGTH = 10255; + + function requestResult(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Private transcript storage failed.')); + }); + } + + function createIndexedDbTransaction(indexedDB) { + if (!indexedDB) return null; + let databasePromise = null; + function openDatabase() { + if (databasePromise) return databasePromise; + databasePromise = new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME, {keyPath:'login'}); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Private transcript storage is unavailable.')); + }); + return databasePromise; + } + return async (work, mode = 'readonly') => { + const database = await openDatabase(); + const transaction = database.transaction(STORE_NAME, mode); + const store = transaction.objectStore(STORE_NAME); + const completed = new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error || new Error('Private transcript storage failed.')); + transaction.onabort = () => reject(transaction.error || new Error('Private transcript storage was interrupted.')); + }); + const result = await work({ + get:key => requestResult(store.get(key)), + put:value => requestResult(store.put(value)), + delete:key => requestResult(store.delete(key)), + }); + await completed; + return result; + }; + } + + function normalizeLogin(value) { + return String(value || '').trim().toLowerCase(); + } + + function normalizeTranscript(value) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TRANSCRIPT_LENGTH); + } + + function createVoiceTranscriptStore({transaction = createIndexedDbTransaction(defaultIndexedDB)} = {}) { + async function load(login) { + const key = normalizeLogin(login); + if (!key || !transaction) return ''; + const record = await transaction(store => store.get(key)); + return normalizeTranscript(record?.transcript); + } + + async function save(login, transcript) { + const key = normalizeLogin(login); + const value = normalizeTranscript(transcript); + if (!key || !value || !transaction) return; + await transaction(store => store.put({login:key, transcript:value}), 'readwrite'); + } + + async function clear(login) { + const key = normalizeLogin(login); + if (!key || !transaction) return; + await transaction(store => store.delete(key), 'readwrite'); + } + + return {load, save, clear}; + } + + createVoiceTranscriptStore.databaseName = dbName; + return createVoiceTranscriptStore; +}); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 322cdde..260d671 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -23,7 +23,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" FEATURE_SOURCES = { "comment-actions": ("static/conversation.js", "static/comment-actions.js"), "issue-capture": ( - "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js", + "static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js", ), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), "push-notifications": ("static/push-notifications.js",), diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 80c3642..112f294 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -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" diff --git a/tests/test_private_data_registry.py b/tests/test_private_data_registry.py index c9971de..cb83fd2 100644 --- a/tests/test_private_data_registry.py +++ b/tests/test_private_data_registry.py @@ -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", } diff --git a/tests/test_private_device_data.py b/tests/test_private_device_data.py index da53942..63ca37e 100644 --- a/tests/test_private_device_data.py +++ b/tests/test_private_device_data.py @@ -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"}] diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index c2879e6..33a81ad 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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"] diff --git a/tests/test_voice_issue_capture.py b/tests/test_voice_issue_capture.py index cdaeaa9..f02f4e2 100644 --- a/tests/test_voice_issue_capture.py +++ b/tests/test_voice_issue_capture.py @@ -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