Translation
- Stop scrubbing markdown before sending it to LibreTranslate. The scrub
deleted ordered-list numbering ("1. Give amoxicillin" -> "Give amoxicillin"),
flattened tables into ambiguous whitespace and ate underscores inside
identifiers. Raw markdown now goes to the translator unchanged.
- Render the translation through the same markdown pipeline as the original
bubble, with the message's own sources, so [n] markers come back as the usual
clickable .assistant-cite chips instead of escaped literal text. Headings,
lists and tables survive with them.
- When the translator drops citation markers, surface the affected sources in a
recovery block rather than letting the evidence disappear.
- Image cards are live nodes: they are now re-attached on every path out of a
translation (success, failure and Show original), so a failed translation no
longer silently removes a generating image from the message.
Patient take home
- Add a language selector to the take-home modal, reusing the existing
/translate endpoint and offering only what the local LibreTranslate reports.
- Copy, Export and Email carry what the caregiver is actually reading; the
original stays canonical behind "Original".
Conversation budget
- The admin field no longer prefills with the environment value, which turned
the next Save into an accidental override and made the documented "leave
empty to use the environment" path unreachable. The effective limit is shown
as a placeholder instead.
- Report source 'default' honestly instead of naming an unset env var.
- The load-failure notice now lands on the <p> instead of an <input>'s
textContent, where it rendered nothing.
- One validator for the budget everywhere: conversationLimit() replaces a
parseInt that accepted "120000abc".
Other
- /assistant is addressed by its URL, not by ped_last_tab, so "/" no longer
reopens the assistant; the URL follows tab changes and Back leaves it.
- Remove the dead DeepL path (it referenced an undefined DEEPL_BASES) and stop
offering admins a provider the server silently ignores.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
127 lines
9.3 KiB
JavaScript
127 lines
9.3 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { pathToFileURL } = require('node:url');
|
|
const { JSDOM } = require('jsdom');
|
|
const root = path.join(__dirname, '..');
|
|
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
|
const tick = () => new Promise(resolve => setImmediate(resolve));
|
|
|
|
function browserGlobals(t, dom, fetch, toasts) {
|
|
const values = { window: dom.window, document: dom.window.document, fetch, getAuthHeaders: () => ({ 'X-Test': 'synthetic' }), showToast: (...args) => toasts.push(args) };
|
|
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
|
|
Object.assign(global, values);
|
|
Object.assign(dom.window, { fetch, getAuthHeaders: values.getAuthHeaders });
|
|
t.after(() => {
|
|
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
|
|
dom.window.close();
|
|
});
|
|
}
|
|
|
|
test('native admin initializer preserves lazy navigation, assistant actions and read-only ENV budget metadata', async t => {
|
|
const dom = new JSDOM('<button class="tab-btn active" data-tab="home">Home</button><button class="tab-btn" data-tab="admin">Admin</button><div id="home-tab" class="tab-content" data-component="home" data-loaded="1"></div><div id="admin-tab" class="tab-content" data-component="admin"></div>', { runScripts: 'outside-only', url: 'https://app.example' });
|
|
const calls = []; const toasts = [];
|
|
const fetch = async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
let data = {};
|
|
if (url.startsWith('/components/admin.html?')) return { ok: true, text: async () => read('public/components/admin.html') };
|
|
if (url === '/api/models') data = { models: [{ id: 'chat', name: 'Chat' }], defaultModel: 'chat' };
|
|
if (url === '/api/admin/config') {
|
|
assert.ok(document.getElementById('assistant-chat-model'), 'tabChanged fires after lazy markup exists');
|
|
data = { success: true, conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' }, config: [{ key: 'clinical_assistant.conversation_chars', value: '999999' }, { key: 'clinical_assistant.image_model', value: 'saved-image' }] };
|
|
}
|
|
if (url === '/api/admin/config/image-models/discover') data = { models: [{ id: 'image', name: 'Image' }] };
|
|
if (url === '/api/admin/clinical-assistant/prompt-pool') data = { success: true, meta: { count: 3 }, snapshots: [{ id: 7, count: 3 }] };
|
|
if (options.method) data = { success: true, duration: 1, response: 'Synthetic', meta: { count: 4 }, snapshots: [{ id: 7, count: 4 }] };
|
|
return { ok: true, json: async () => data };
|
|
};
|
|
browserGlobals(t, dom, fetch, toasts);
|
|
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
|
|
assert.equal(calls.length, 0, 'registration must not eagerly load assistant settings');
|
|
// JSDOM does not execute native script tags. Import the unmodified ESM above,
|
|
// then execute the real classic app entrypoint and its component loader.
|
|
await tick();
|
|
dom.window.eval(read('public/js/app.js'));
|
|
document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
|
|
await tick();
|
|
assert.equal(document.getElementById('assistant-chat-model'), null);
|
|
document.querySelector('[data-tab="admin"]').click();
|
|
await tick(); await tick();
|
|
assert.equal(document.getElementById('admin-tab').dataset.loaded, '1');
|
|
assert.equal(document.getElementById('assistant-chat-model').options[0].textContent, 'Use global default (chat)');
|
|
assert.equal(document.getElementById('assistant-image-model'), null, 'single image-model dropdown lives in workflow-image-settings');
|
|
assert.match(document.getElementById('assistant-prompt-pool-status').textContent, /3 prompts/);
|
|
const budget = document.getElementById('assistant-conversation-budget');
|
|
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
|
|
assert.equal(budget.type, 'number', 'the conversation budget is an editable admin input');
|
|
assert.equal(budget.readOnly, false, 'the budget is admin-editable');
|
|
assert.equal(budget.value, '999999', 'prefilled with the saved override');
|
|
const budgetMeta = document.getElementById('assistant-conversation-budget-meta').textContent;
|
|
assert.match(budgetMeta, /CLINICAL_ASSISTANT_CONVERSATION_CHARS/);
|
|
assert.match(budgetMeta, /Clear this field/, 'the admin is told how to get back to the environment value');
|
|
assert.match(budgetMeta, /240,000/, 'and what that environment value currently is');
|
|
const initialConfigLoads = calls.filter(c => c.url === '/api/admin/config').length;
|
|
document.querySelector('[data-tab="home"]').click(); await tick();
|
|
document.querySelector('[data-tab="admin"]').click(); await tick();
|
|
assert.equal(calls.filter(c => c.url.startsWith('/components/admin.html?')).length, 1);
|
|
assert.equal(calls.filter(c => c.url === '/api/admin/config').length, initialConfigLoads, 'initializer loads once across revisits');
|
|
|
|
const writes = () => calls.filter(c => c.options.method === 'PUT');
|
|
const save = document.getElementById('btn-save-assistant-config');
|
|
save.click(); await tick();
|
|
assert.equal(writes().length, 7);
|
|
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
|
|
'clinical_assistant.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.translate_provider'
|
|
]);
|
|
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));
|
|
|
|
document.getElementById('btn-test-assistant-chat-model').click(); await tick();
|
|
assert.deepEqual(JSON.parse(calls.find(c => c.url === '/api/admin/config/models/test').options.body), { modelId: 'chat' });
|
|
document.getElementById('btn-regenerate-assistant-prompt-pool').click(); await tick();
|
|
assert.equal(document.getElementById('btn-regenerate-assistant-prompt-pool').disabled, false);
|
|
document.getElementById('assistant-prompt-pool-snapshots').value = '7';
|
|
document.getElementById('btn-restore-assistant-prompt-pool').click(); await tick();
|
|
assert.deepEqual(JSON.parse(calls.find(c => c.url.endsWith('/prompt-pool/restore')).options.body), { id: 7 });
|
|
assert.equal(document.getElementById('assistant-custom-image-model'), null);
|
|
assert.equal(document.getElementById('btn-test-assistant-image-model'), null);
|
|
assert.equal(document.getElementById('btn-use-custom-assistant-image-model'), null);
|
|
await tick();
|
|
});
|
|
|
|
test('real extracted initializer never invents a cap when metadata is missing, invalid or returns 503', async t => {
|
|
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
|
|
for (const data of [{}, { success: false, error: 'Invalid environment' }, { success: true, conversationBudget: { limit: 1000001 } }]) {
|
|
await t.test(JSON.stringify(data), async t => {
|
|
const dom = new JSDOM(read('public/components/admin.html'));
|
|
browserGlobals(t, dom, async () => ({ status: 503, json: async () => data }), []);
|
|
initClinicalAssistantAdmin(value => value);
|
|
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
|
await tick();
|
|
assert.equal(document.getElementById('assistant-conversation-budget').value, '', 'failed load leaves the budget input empty');
|
|
// The failure notice must land on the <p>; writing it to the <input>
|
|
// rendered nothing, so admins saw a stale message on a broken load.
|
|
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /Conversation budget unavailable/);
|
|
assert.doesNotMatch(document.getElementById('assistant-conversation-budget-meta').textContent, /Leave empty/, 'no misleading guidance when the load failed');
|
|
});
|
|
}
|
|
});
|
|
|
|
test('real extracted initializer displays the server default only when returned as metadata', async t => {
|
|
const dom = new JSDOM(read('public/components/admin.html'));
|
|
browserGlobals(t, dom, async () => ({ ok: true, json: async () => ({ success: true, config: [], models: [], conversationBudget: {
|
|
limit: 120000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'default'
|
|
} }) }), []);
|
|
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
|
|
initClinicalAssistantAdmin(value => value);
|
|
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
|
await tick();
|
|
// No saved override: the field stays EMPTY so Save cannot silently promote the
|
|
// environment value into a stored override. The number is shown as a placeholder.
|
|
const noOverride = document.getElementById('assistant-conversation-budget');
|
|
assert.equal(noOverride.value, '', 'no override means an empty field, not a prefilled one');
|
|
assert.equal(noOverride.placeholder, '120000', 'the effective limit is shown as a placeholder');
|
|
assert.equal(noOverride.readOnly, false);
|
|
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /built-in default/,
|
|
"source 'default' must not be reported as coming from the environment variable");
|
|
});
|