pediatric-ai-scribe-v3/test/patient-takehome.test.js
Daniel d04a3fe53b fix: translate as HTML so tables and emphasis survive; stop repeat image generation
Translation formatting
LibreTranslate's text mode destroys markdown syntax. Verified against the live
container: table pipes come back as "←", the |---| delimiter row is translated
as prose ("Silencio."), and "**bold**" returns as "** bold**" which no longer
renders. Its html mode leaves tags — and bare [n] markers — completely intact.
Messages and the patient take home are now rendered to HTML, simplified (maths
and UI chrome flattened to text), and translated as HTML. Citation chips are
re-linked from the returned markers afterwards, which is the step the original
html path was missing. A text-mode fallback remains for builds that reject html.

Repeat image generation
Typing "Окей" or "Nice" after an image turn produced another image every time:
the model saw its own "I'll generate an educational image…" in the history and
repeated it. Recognising acknowledgements in every language is not possible, so
the rule is inverted — a short follow-up (<=3 words) that mentions nothing about
a picture does not get the image tool offered at all when the previous assistant
turn produced an image. Terse repeat requests ("again", "ещё", "another one")
still work. The worst case is that a terse question is answered in text.

In-chat images
Generated images render as a 320x240 thumbnail instead of filling the bubble,
and the image itself opens the full-resolution preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 18:49:58 +02:00

392 lines
25 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const { webcrypto } = require('node:crypto');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
// ── Server: generate + email routes ────────────────────────────────────────
function server(t, overrides = {}) {
const module = { exports: {} };
const emailCalls = [];
const aiCalls = [];
const settings = Object.assign({
'clinical_assistant.chat_model': 'synthetic-chat',
'models.default': '',
'clinical_assistant.system_behavior': '',
'clinical_assistant.search_limit': '8',
'clinical_assistant.context_chars': '1400',
'clinical_assistant.image_model': 'openai-gpt-image-1',
'clinical_assistant.patient_takehome_behavior': ''
}, overrides.settings || {});
const quiet = { log() {}, error() {}, warn() {}, audit() {} };
const mocks = {
express: require('express'),
axios: { get: async () => { throw new Error('unexpected axios call'); }, post: async () => { throw new Error('unexpected axios call'); } },
'../db/database': {
get: async () => null,
getSetting: async key => settings[key],
query: async () => ({ rows: [] })
},
'../middleware/auth': { authMiddleware: (req, res, next) => next() },
'../utils/ai': { callAI: async (messages, options) => { aiCalls.push({ messages, options }); return { content: 'Take home [1] text.', model: 'synthetic-chat' }; }, callAIStream: async () => { throw new Error('unexpected'); }, activeProvider: 'synthetic', discoverModels: async () => [], vertexClient: null, litellmClient: null, applyImageAttachments: x => x },
'../utils/generatedImages': { workflows: ['clinical_assistant'], snapshot: async () => ({}), enqueue: async () => ({}), tick: async () => {}, ready: async () => {} },
'../utils/imageTool': { tools: [], dispatch: async x => x },
'../utils/generatedImageLinks': { validateChat: () => null },
'../utils/logger': quiet,
'../utils/crypto': { randomUUID: () => 'synthetic-uuid', encryptString: s => 'enc:' + s, decryptString: s => s.replace(/^enc:/, ''), encryptBuffer: b => b, decryptBuffer: b => b },
'../utils/redis': { get: async () => null, set: async () => {} },
'../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({ ready: async () => {} }) },
'../utils/clinicalMcpClient': { search: async () => ({ sources: [] }), semanticSearch: async () => ({ sources: [{ number: 1, title: 'Synthetic', excerpt: 'Synthetic reference.' }] }), multimodal: async () => ({ sources: [] }), warmup: async () => {} },
'../utils/clinicalRetrieval': { cleanSourceExcerpt: s => s, normalizeMcpSearchResponse: () => [{ number: 1, title: 'Synthetic', excerpt: 'Synthetic reference.' }], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] },
'../utils/clinicalAnswer': require('../src/utils/clinicalAnswer'),
'../utils/clinicalConversation': require('../src/utils/clinicalConversation'),
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'),
'../utils/patientTakehome': require('../src/utils/patientTakehome'),
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'),
'./auth': { __sendEmail: async (to, subject, html) => { emailCalls.push({ to, subject, html }); return overrides.smtpConfigured !== false; } },
'markdown-it': require('markdown-it'),
'../utils/litellm': { getLiteLLMHeaders: () => ({}) }
};
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder,
process: { env: Object.assign({ CLINICAL_ASSISTANT_MCP_WARMUP: 'false' }, overrides.env || {}) },
setTimeout() {},
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; }
});
async function request(method, routePath, body) {
const layer = module.exports.stack.find(layer => layer.route && layer.route.path === routePath && layer.route.methods[method]);
assert.ok(layer, 'route missing: ' + method + ' ' + routePath);
const handler = layer.route.stack.find(layer => layer.method === method).handle;
const res = { statusCode: 200, headers: {}, body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, setHeader() {}, write() {}, end() {}, flushHeaders() {} };
await handler({ body, user: { id: 7 }, ip: 'synthetic' }, res);
return res;
}
return { request, aiCalls, emailCalls, quiet };
}
test('POST /clinical-assistant/patient-takehome rewrites the answer in plain language without citations', async t => {
const s = server(t);
const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: 'Dose: 10 mg/kg [1].' });
assert.equal(res.statusCode, 200);
assert.equal(res.body.success, true);
assert.equal(res.body.text, 'Take home text.', 'citation tokens are scrubbed from the result');
assert.equal(s.aiCalls.length, 1);
assert.match(s.aiCalls[0].messages[0].content, /plain language/, 'default patient-language behavior');
assert.match(s.aiCalls[0].messages[1].content, /Dose: 10 mg\/kg/);
assert.equal(s.aiCalls[0].options.model, 'synthetic-chat', 'chat model resolved from settings');
});
test('patient take-home refuses empty answers without contacting the model', async t => {
const s = server(t);
const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: ' ' });
assert.equal(res.statusCode, 400);
assert.equal(s.aiCalls.length, 0);
});
test('patient take-home honors an admin behavior override', async t => {
const s = server(t, { settings: { 'clinical_assistant.patient_takehome_behavior': 'Custom parent-friendly rewrite.' } });
const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: 'Anything.' });
assert.equal(res.statusCode, 200);
assert.match(s.aiCalls[0].messages[0].content, /Custom parent-friendly rewrite/);
});
test('POST /clinical-assistant/patient-takehome/email validates the address and the body', async t => {
const s = server(t);
const badEmail = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'not-an-email', text: 'Take home' });
assert.equal(badEmail.statusCode, 400);
assert.equal(s.emailCalls.length, 0);
const badBody = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: ' ' });
assert.equal(badBody.statusCode, 400);
assert.equal(s.emailCalls.length, 0);
});
test('patient take-home email reports honestly when SMTP is not configured', async t => {
const s = server(t, { smtpConfigured: false });
const res = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: 'Take home' });
assert.equal(res.statusCode, 503);
assert.equal(res.body.code, 'SMTP_NOT_CONFIGURED');
});
test('explicit image requests enqueue a job even when the model only writes text', async t => {
const fs = require('node:fs'); const path = require('node:path');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
const vm = require('node:vm');
const module = { exports: {} };
const jobs = [];
const mocks = {
express: require('express'),
axios: {},
'../db/database': { get: async () => null, getSetting: async () => '', all: async () => [], run: async () => ({ changes: 1, lastInsertRowid: 1 }), query: async () => ({ rows: [] }) },
'../middleware/auth': { authMiddleware: (req, res, next) => next() },
'../utils/ai': { callAI: async () => ({ content: 'Create a poster showing oxygen delivery for neonates.' }), callAIStream: async () => ({ content: 'Create a poster showing oxygen delivery for neonates.' }) },
'../utils/generatedImages': { service: () => ({ enqueue: async (owner, workflow, input, key, replay, context, model) => { jobs.push({ owner, workflow, input, model }); return { jobId: 'job-x', status: 'pending', imageUrl: null }; } }), imageContext: (r, h) => ({ request: r, history: h }), requestKey: b => 'k' + String(b).length },
'../utils/imageTool': { tools: [], dispatch: async ai => ai },
'../utils/generatedImageLinks': { validateChat: () => null },
'../utils/logger': { error() {}, audit() {}, warn() {} },
'../utils/crypto': { randomUUID: () => 'u', encryptString: v => v, decryptString: v => v },
'../utils/redis': { get: async () => null, set: async () => {} },
'../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
'../utils/clinicalMcpClient': { search: async () => ({ sources: [] }), semanticSearch: async () => ({ sources: [{ number: 1, title: 'Synthetic', excerpt: 'Synthetic reference.' }] }), multimodal: async () => ({ sources: [] }), warmup: async () => {} },
'../utils/clinicalRetrieval': { cleanSourceExcerpt: s => s, normalizeMcpSearchResponse: () => [{ number: 1, title: 'Synthetic', excerpt: 'Synthetic reference.' }], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] },
'../utils/clinicalAnswer': require('../src/utils/clinicalAnswer'),
'../utils/clinicalConversation': require('../src/utils/clinicalConversation'),
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'),
'../utils/patientTakehome': require('../src/utils/patientTakehome'),
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'),
'./auth': { __sendEmail: async () => false },
'markdown-it': require('markdown-it'),
'../utils/litellm': { getLiteLLMHeaders: () => ({}) }
};
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
module, exports: module.exports, console: { log() {}, info() {}, error() {}, warn() {} }, Buffer, Map, TextEncoder,
process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', LIBRETRANSLATE_URL: 'http://libretranslate:5000' } },
setTimeout() {}, require(name) { if (!Object.hasOwn(mocks, name)) throw new Error('Unexpected import: ' + name); return mocks[name]; }
});
const layer = module.exports.stack.find(l => l.route && l.route.path === '/clinical-assistant/chat' && l.route.methods.post).route.stack.find(l => l.method === 'post').handle;
const res = { statusCode: 200, json(b) { this.body = b; return this; }, status(c) { this.statusCode = c; return this; }, set() {}, setHeader() {}, flushHeaders() {} };
await layer({ body: { message: 'create an image of oxygen delivery' }, user: { id: 7 }, ip: 'x' }, res);
assert.equal(jobs.length, 1, 'a real image job was enqueued despite the text-only answer — route status ' + res.statusCode + ' body ' + JSON.stringify(res.body));
assert.match(jobs[0].input.prompt, /poster/, 'the model answer text became the image prompt');
assert.equal(jobs[0].owner, 7, 'owner-bound');
});
test('patient take-home email sends plain text wrapped in a simple caregiver note', async t => {
const s = server(t);
const res = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: 'Give fluids & rest.' });
assert.equal(res.statusCode, 200);
assert.equal(s.emailCalls.length, 1);
assert.equal(s.emailCalls[0].to, 'parent@example.com');
assert.match(s.emailCalls[0].subject, /Patient Take Home/);
assert.match(s.emailCalls[0].html, /Give fluids &amp; rest\./, 'HTML-escaped plain text');
});
// ── Frontend: button → modal → copy/export/email ───────────────────────────
function client(t, options = {}) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-takehome-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const calls = [];
const toasts = [];
const copies = [];
const downloads = [];
window.URL.createObjectURL = blob => { downloads.push(blob); return 'blob:synthetic'; };
window.URL.revokeObjectURL = () => {};
const apiFetch = async (url, options) => {
calls.push({ url, options });
if (url === '/api/clinical-assistant/patient-takehome') {
return new Response(JSON.stringify({ success: true, text: options.takehomeText || '**Rest** and drink fluids.\n\n- Give medicine as directed' }));
}
if (url === '/api/clinical-assistant/patient-takehome/email') {
if (options.failEmail) return new Response(JSON.stringify({ error: 'Email is not configured on this server yet' }), { status: 503 });
return new Response(JSON.stringify({ success: true }));
}
if (url === '/api/clinical-assistant/translate/languages') {
return new Response(JSON.stringify({ success: true, languages: { libretranslate: ['en', 'es', 'de'] } }));
}
if (url === '/api/clinical-assistant/translate') {
const body = JSON.parse(options.body);
if (options.failTranslate) return new Response(JSON.stringify({ error: 'Local translation service is unreachable.' }), { status: 502 });
return new Response(JSON.stringify({ success: true, translated: '[' + body.target + '] ' + body.message, provider: body.provider }));
}
return new Response(JSON.stringify({ success: true, chats: [] }));
};
const context = { window, document: window.document, console, URL: window.URL, Blob, TextDecoder, AbortController, crypto: webcrypto,
setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }),
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
translateAssistantMessage: (message, target, provider, format) => apiFetch('/api/clinical-assistant/translate', { method: 'POST', headers: {}, body: JSON.stringify({ message, target, provider, format: format || 'text' }), failTranslate: options.failTranslate }).then(r => r.json()),
getAuthHeaders: () => ({ 'X-Synthetic': '1' }),
fetchSavedAssistantChat: async id => ({ success: true, chat: { id, payload: { version: 2, messages: [{ role: 'user', content: 'Old question' }] } } }),
openAssistantStream: async (payload) => {
calls.push({ url: 'openAssistantStream', payload });
return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Answer.', sources: [] }) + '\n\n');
},
startAssistantImageJob: async (prompt, history) => { calls.push({ url: 'image-job', prompt, history }); return { success: true, jobId: 'job-1' }; },
fetch: apiFetch };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
context.bindEvents();
t.after(() => window.close());
return { context, document: window.document, window, calls, toasts, copies, downloads };
}
test('Patient take home button opens the modal, generates, and offers Copy/Export/Send', async t => {
const app = client(t);
const c = app.context;
c.appendMessage('assistant', 'Answer [1].', []);
c.lastAnswer = 'Answer [1].';
app.document.getElementById('btn-assistant-takehome').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const modal = app.document.getElementById('assistant-takehome-modal');
assert.ok(modal, 'modal opened');
assert.match(modal.querySelector('.assistant-takehome-result').innerHTML, /<strong>Rest<\/strong>/, 'take-home renders through the shared markdown pipeline');
assert.match(modal.querySelector('.assistant-takehome-result').innerHTML, /<ul>/, 'bullets render too');
assert.match(modal.querySelector('.assistant-takehome-result').textContent, /Rest and drink fluids/);
assert.ok(modal.querySelector('[data-assistant-takehome-copy]'));
assert.ok(modal.querySelector('[data-assistant-takehome-export]'));
assert.ok(modal.querySelector('[data-assistant-takehome-send]'));
const call = app.calls.find(c => c.url === '/api/clinical-assistant/patient-takehome');
assert.equal(JSON.parse(call.options.body).answer, 'Answer [1].');
});
test('take home refuses honestly when there is no answer yet', async t => {
const app = client(t);
app.document.getElementById('btn-assistant-takehome').click();
await new Promise(r => setImmediate(r));
assert.equal(app.document.getElementById('assistant-takehome-modal'), null);
assert.equal(app.calls.filter(c => c.url === '/api/clinical-assistant/patient-takehome').length, 0);
assert.match(app.toasts[0][0], /Ask a question first/);
});
test('email send succeeds and clears the field; failure shows the server message', async t => {
const app = client(t);
const c = app.context;
c.appendMessage('assistant', 'Answer.', []);
c.lastAnswer = 'Answer.';
app.document.getElementById('btn-assistant-takehome').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const email = app.document.getElementById('assistant-takehome-email');
email.value = 'parent@example.com';
app.document.querySelector('[data-assistant-takehome-send]').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.equal(email.value, '', 'field cleared after success');
assert.ok(app.toasts.some(t => /sent to parent@example\.com/.test(t[0])));
});
test('Create image dialog: describe it or pick a chat; the latest chat is one tap away', async t => {
const app = client(t);
const c = app.context;
c.fetchSavedAssistantChats = async () => ({ success: true, chats: [{ id: 2, title: 'Older chat', updated_at: new Date().toISOString() }, { id: 9, title: 'Newest chat', updated_at: new Date().toISOString() }] });
await c.loadSavedChats();
app.document.getElementById('btn-assistant-create-image').click();
const modal = app.document.getElementById('assistant-create-image-modal');
assert.ok(modal, 'dialog opens');
assert.ok(modal.querySelector('#create-image-description'));
const options = [...modal.querySelectorAll('#create-image-chat option')].map(o => o.textContent);
assert.deepEqual(options, ['No chat', 'Older chat', 'Newest chat'], 'no-chat default, saved chats newest first');
// empty description with No chat refuses honestly
modal.querySelector('#btn-create-image-generate').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.equal(app.calls.filter(c => c.url === 'image-job').length, 0, 'nothing to generate without a description or a chat');
assert.match(app.document.getElementById('create-image-progress').textContent, /Describe the image/);
// type a description with No chat: description-only context
app.document.getElementById('create-image-description').value = 'A poster on asthma care';
modal.querySelector('#btn-create-image-generate').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const job = app.calls.find(c => c.url === 'image-job');
assert.ok(job, 'generation requested');
assert.equal(job.prompt, 'A poster on asthma care');
assert.ok(Array.isArray(job.history) && job.history.length === 0, 'No chat sends no context');
// close the popup, then reopen to pick an older chat
app.document.querySelector('[data-create-image-close]').click();
await new Promise(r => setImmediate(r));
app.document.getElementById('btn-assistant-create-image').click();
app.document.getElementById('create-image-chat').value = '2';
app.document.getElementById('btn-create-image-generate').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const jobs = app.calls.filter(c => c.url === 'image-job');
assert.equal(jobs.length, 2);
assert.equal(jobs[1].history.length, 1);
assert.equal(jobs[1].history[0].role, 'user');
assert.equal(jobs[1].history[0].content, 'Old question', 'generates from the selected chat');
});
test('tapping an example question asks it immediately (no empty sends)', async t => {
const app = client(t);
const btn = app.document.querySelector('[data-assistant-example]');
btn.click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const asks = app.calls.filter(c => c.url === 'openAssistantStream');
assert.equal(asks.length, 1, 'one request fired from the tap');
assert.equal(asks[0].payload.message, btn.getAttribute('data-assistant-example'), 'the example text is the message');
});
test('translate picker filters languages by the local provider availability', async t => {
const app = client(t);
const c = app.context;
c.appendMessage('assistant', 'Answer.', []);
const row = app.document.querySelector('.assistant-msg');
row.querySelector('[data-assistant-msg-translate]').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const pop = app.document.querySelector('[data-assistant-translate-pop]');
assert.ok(pop, 'picker opened');
const langs = [...pop.querySelectorAll('[data-assistant-translate-lang]')].map(b => b.getAttribute('data-assistant-translate-lang'));
assert.deepEqual(langs, ['en', 'es', 'de'], 'only languages the local LibreTranslate supports are offered');
});
// ── Take home translation: the artifact the caregiver actually leaves with ──
async function openTakehome(app) {
const c = app.context;
c.appendMessage('assistant', 'Answer.', []);
c.lastAnswer = 'Answer.';
app.document.getElementById('btn-assistant-takehome').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
return app.document.getElementById('assistant-takehome-modal');
}
test('take home offers only the languages the local translator actually has', async t => {
const app = client(t);
const modal = await openTakehome(app);
await new Promise(r => setImmediate(r));
const select = modal.querySelector('[data-assistant-takehome-lang]');
assert.ok(select, 'the caregiver-facing artifact can be translated');
const values = Array.from(select.options).map(o => o.value);
assert.deepEqual(values, ['', 'en', 'es', 'de'], 'original plus exactly what LibreTranslate reports');
assert.equal(select.value, '', 'starts on the original');
});
test('translating the take home rerenders it and carries into Copy, Export and Email', async t => {
const app = client(t);
const modal = await openTakehome(app);
await new Promise(r => setImmediate(r));
const select = modal.querySelector('[data-assistant-takehome-lang]');
select.value = 'es';
select.dispatchEvent(new app.window.Event('change', { bubbles: true }));
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const sent = JSON.parse(app.calls.find(c => c.url === '/api/clinical-assistant/translate').options.body);
assert.equal(sent.target, 'es');
assert.equal(sent.format, 'html', 'html mode keeps emphasis and tables intact');
assert.match(sent.message, /<strong>Rest<\/strong>/, 'the generated take-home is what gets translated');
const result = modal.querySelector('.assistant-takehome-result');
assert.match(result.textContent, /\[es\]/, 'the modal shows the translation');
assert.ok(result.querySelector('strong'), 'still rendered as markdown, not escaped text');
modal.querySelector('[data-assistant-takehome-send]').closest('.assistant-takehome-email')
.querySelector('input').value = 'parent@example.com';
modal.querySelector('[data-assistant-takehome-send]').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const emailed = JSON.parse(app.calls.find(c => c.url === '/api/clinical-assistant/patient-takehome/email').options.body);
assert.match(emailed.text, /^\[es\] /, 'the caregiver is emailed the language they were shown');
select.value = '';
select.dispatchEvent(new app.window.Event('change', { bubbles: true }));
await new Promise(r => setImmediate(r));
assert.doesNotMatch(modal.querySelector('.assistant-takehome-result').textContent, /\[es\]/, 'Original restores the source text');
});
test('a failed take-home translation keeps the original on screen and says so', async t => {
const app = client(t, { failTranslate: true });
const modal = await openTakehome(app);
await new Promise(r => setImmediate(r));
const select = modal.querySelector('[data-assistant-takehome-lang]');
select.value = 'es';
select.dispatchEvent(new app.window.Event('change', { bubbles: true }));
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.match(modal.querySelector('.assistant-takehome-result').textContent, /Rest and drink fluids/);
assert.equal(select.value, '', 'the select does not claim a translation that never arrived');
assert.match(app.toasts[app.toasts.length - 1][0], /unreachable/i);
});