pediatric-ai-scribe-v3/test/assistant-voice-mode.test.js
Daniel f3c3f47d99
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
fix: voice mode reads the answer that just arrived, and reads what the page shows
Two bugs, one cause each.

It read the previous answer. setBusy(false) is what announces
assistant-answer-done, and it ran before lastAnswer was assigned — so every
listener was handed the answer before last. It now fires after the answer exists
both in that variable and on the page. A test asserts the order, because the
order is the whole bug.

And it read the markdown. The better answer than unpicking the markup is not to
have any: the rendered bubble is already the answer with its headings, emphasis
and tables resolved, so voice mode reads that. It cannot drift from what the
reader is looking at, and it needs no rules about what "##" sounds like. Read
from a clone, with the parts that are not the answer removed — the action
buttons, the sources list, the follow-up suggestions, code blocks and tables —
so the page itself is untouched. A bubble still thinking is never read.

speakableText() stays as the fallback for when the bubble cannot be found, since
raw markdown read aloud is worse than silence.

Separately: e2e/seed.js hashed with bcrypt directly, so seeded accounts did not
exercise the argon2id path production writes. It uses the app's own hasher now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 21:10:50 +02:00

87 lines
4.8 KiB
JavaScript

// ============================================================
// ASSISTANT VOICE MODE
// ============================================================
// The hands-free overlay: tap, speak, hear the answer. Two things about it were
// wrong in ways a person notices immediately.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
const src = read('public/js/clinicalAssistant.js');
// The function under test is a closure inside a browser IIFE, so it is pulled
// out by source rather than imported.
const speakableText = (() => {
const from = src.indexOf('function speakableText');
const to = src.indexOf('function speakAnswerVoice');
// eslint-disable-next-line no-eval
return eval(src.slice(from, to) + '; speakableText');
})();
test('ending the call stops the voice', () => {
// It stopped listening and aborted the request but never stopped playback,
// and the <audio> element was a local with no handle kept — so the answer
// talked on until the page was reloaded.
assert.match(src, /function conversationStopSpeaking\(\)/);
assert.match(src, /if \(window\.speechSynthesis\) window\.speechSynthesis\.cancel\(\)/);
assert.match(src, /playing\.pause\(\); playing\.src = '';/);
const end = src.slice(src.indexOf('function endConversationMode'));
assert.ok(end.indexOf('conversationStopSpeaking()') < end.indexOf('conversationStopListening()'),
'hanging up silences the answer');
// Speaking over the previous answer is how an assistant talks past you.
assert.match(src, /unlockAudioPlayback\(\);\s*\n\s*\/\/[^\n]*\n\s*conversationStopSpeaking\(\);/);
// A reply that arrives after the overlay closed must not start talking.
assert.match(src, /if \(!conversationMode\.active\) \{ URL\.revokeObjectURL\(url\); return; \}/);
// Both players are tracked, or only one of them can be stopped.
assert.match(src, /conversationMode\.speaking = utter;/);
assert.match(src, /conversationMode\.speaking = audio;/);
});
test('the answer is read only once it exists', () => {
// setBusy(false) announces assistant-answer-done, and it used to run before
// lastAnswer was assigned — so every listener, voice mode included, was handed
// the previous answer. Tested by position, because that is the whole bug.
const body = src.slice(src.indexOf('if (request && request.cancelled) return;'));
const assigned = body.indexOf("lastAnswer = finalData.answer");
const rendered = body.indexOf('replaceLoadingMessage(loading, lastAnswer');
const announced = body.indexOf("setBusy(false, 'Ready')");
assert.ok(assigned < announced, 'the answer is assigned before it is announced');
assert.ok(rendered < announced, 'and rendered before it is announced');
});
test('voice mode reads what the page shows', () => {
// The rendered bubble is the answer with its markup already resolved, so
// reading it is both simpler than unpicking markdown and impossible to drift
// from what the reader is looking at.
assert.match(src, /function renderedAnswerText\(\)/);
assert.match(src, /speakAnswerVoice\(renderedAnswerText\(\) \|\| String\(detail\.answer \|\| ''\)\)/);
// Never the bubble that is still thinking.
assert.match(src, /last\.classList\.contains\('assistant-loading-msg'\)\) return '';/);
// A clone: the things that are not the answer are removed from a copy, not
// from the page.
assert.match(src, /var copy = bubble\.cloneNode\(true\);/);
assert.match(src, /assistant-msg-actions, \.assistant-sources, \.assistant-suggestions, button, pre, table/);
});
test('the markdown fallback is still reduced before it is spoken', () => {
// The raw answer went straight to the speaker. It is markdown, and a browser
// voice reads "#" and "**" aloud or stumbles over them.
assert.match(src, /var text = speakableText\(markdown\);/);
assert.equal(speakableText('## Croup\n\nA **viral** illness.'), 'Croup\nA viral illness.');
assert.equal(speakableText('- One\n- Two'), 'One\nTwo');
assert.equal(speakableText('1. First\n2. Second'), 'First\nSecond');
assert.equal(speakableText('See [the guideline](https://x.y).'), 'See the guideline.');
assert.equal(speakableText('Use `epinephrine` now.'), 'Use epinephrine now.');
// A table read aloud is noise; the prose around it survives.
assert.equal(speakableText('| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter.'), 'After.');
// Citation markers leave no gap before the punctuation they preceded.
assert.equal(speakableText('Clear [1] and also [^2].'), 'Clear and also.');
assert.equal(speakableText('---\n\n> Quoted.'), 'Quoted.');
assert.equal(speakableText('```\ncode\n```\n\nThen.'), 'Then.');
assert.equal(speakableText(''), '');
assert.equal(speakableText(null), '');
});