pediatric-ai-scribe-v3/test/patient-takehome.test.js
Daniel 1f06a19007
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: a text-only model can ask a model that can see; and the image regex is gone
**The regex is gone.** The route ran a pattern over the user's message and
enqueued an image from the answer text when the model had not called the tool.
It was a compatibility path for models without tool calling and it did more harm
than good: it decided in English only, it could not see the conversation, and
"image summary" fell through it while reading as an obvious image request to the
model itself — which was measured, not assumed. A second and worse
decision-maker sitting behind the first. Whether a message deserves a picture is
now the model's call, made from the tool description, which is the only place it
ever belonged.

**Lending eyes.** The same shape, for a different capability. When someone
attaches a photograph and the chat model cannot accept image input, the
attachment was either refused by the provider or silently dropped — an answer
about a picture nobody had looked at, which is worse than a refusal.

The chat model is now offered look_at_image beside the image tool and decides
when to use it. The attachment goes to clinical_assistant.vision_model, whose
description comes back as a tool result, and the chat model answers in its own
voice with its own sources. Only the seeing is delegated; the clinical reasoning
stays with the model an administrator chose. The seeing model is told to report
and not to diagnose, because it has a picture and no context and an opinion from
it would carry weight it has not earned.

Delegation triggers only on an explicit supports_vision: false from the gateway.
An unknown is left alone — most of a roster reports nothing, and treating
silence as blindness would route good models through a detour. The capability
lookup moved to its own module, is cached for five minutes because it runs on
exactly the requests that are already slowest, and is never inferred from the
model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the
other gateway helpers live.

The new setting is guarded like the slide reviewer: a model the gateway calls
text-only cannot be saved as the one that looks at images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 15:43:36 +02:00

405 lines
26 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/visionTool': require('../src/utils/visionTool'),
'../utils/modelVision': { supportsVision: async () => null },
'../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('a text-only answer no longer conjures an image; the model must call the tool', async t => {
// The route used to run a regex over the user's message and enqueue a job from
// the answer text when the model had not called the tool. It decided in
// English only, could not see the conversation, and let "image summary"
// through while the model itself read that as an obvious image request — a
// second, worse decision-maker sitting behind the first. Removed: whether a
// message deserves a picture is the model's call, made from the tool
// description.
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/visionTool': require('../src/utils/visionTool'),
'../utils/modelVision': { supportsVision: async () => null },
'../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, 0, 'no job without a tool call, however the message is worded');
assert.equal(res.statusCode, 200, 'and the answer is still returned');
const route = read('src/routes/clinicalAssistant.js');
assert.doesNotMatch(route, /IMAGE_REQUEST_PATTERN|isExplicitImageRequest|dispatchImageRequestFallback/);
});
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);
});