fix: separate Scribe/Clinical/Learning prompt sections; drop model classification entirely with scrub migration
This commit is contained in:
parent
13fca7887d
commit
e7599128ec
4 changed files with 124 additions and 63 deletions
21
migrations/1780000000000_drop-model-classification.js
Normal file
21
migrations/1780000000000_drop-model-classification.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Model classification (cost/tag/category) is discontinued: scrub stored custom
|
||||||
|
// model JSON and refuse re-adding those keys at the application layer.
|
||||||
|
exports.up = pgm => {
|
||||||
|
pgm.sql(`
|
||||||
|
UPDATE app_settings
|
||||||
|
SET value = (
|
||||||
|
SELECT jsonb_agg(jsonb_build_object('id', item->>'id', 'name', item->>'name'))::text
|
||||||
|
FROM jsonb_array_elements(value::jsonb) AS item
|
||||||
|
WHERE item ? 'id' AND item ? 'name'
|
||||||
|
)
|
||||||
|
WHERE key = 'models.custom'
|
||||||
|
AND value IS NOT NULL
|
||||||
|
AND value <> ''
|
||||||
|
AND value::jsonb IS NOT NULL
|
||||||
|
AND value::jsonb <> '[]'::jsonb;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = pgm => {
|
||||||
|
// No historical classification data remains to restore; nothing to do.
|
||||||
|
};
|
||||||
|
|
@ -161,6 +161,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── CMS: AI Scribe Prompts ─────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-robot"></i> AI Scribe</h3>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px;">
|
||||||
|
<div id="cms-scribe-prompts">Loading Scribe prompts...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── CMS: SMTP Email Server ─────────────────────────────────── -->
|
<!-- ── CMS: SMTP Email Server ─────────────────────────────────── -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|
@ -307,9 +317,13 @@
|
||||||
<strong class="admin-row-label">Conversation input budget</strong>
|
<strong class="admin-row-label">Conversation input budget</strong>
|
||||||
<p id="assistant-conversation-budget" role="status" style="margin:0;font-size:13px;color:var(--g600);">Loading server budget metadata...</p>
|
<p id="assistant-conversation-budget" role="status" style="margin:0;font-size:13px;color:var(--g600);">Loading server budget metadata...</p>
|
||||||
</div>
|
</div>
|
||||||
<section aria-labelledby="prompts-heading">
|
<section aria-labelledby="clinical-prompts-heading">
|
||||||
<h4 id="prompts-heading">Prompts</h4>
|
<h4 id="clinical-prompts-heading">Clinical Assistant prompts</h4>
|
||||||
<div id="cms-prompt-editor">Loading prompts...</div>
|
<div id="cms-clinical-prompts">Loading clinical prompts...</div>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="learning-prompts-heading">
|
||||||
|
<h4 id="learning-prompts-heading">Learning prompts</h4>
|
||||||
|
<div id="cms-learning-prompts">Loading Learning prompts...</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="workflow-image-settings" aria-label="Workflow image settings"></section>
|
<section id="workflow-image-settings" aria-label="Workflow image settings"></section>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -559,23 +559,33 @@ function adminTabActive() {
|
||||||
// General settings reloads must not replace any prompt drafts.
|
// General settings reloads must not replace any prompt drafts.
|
||||||
if (promptsLoaded || promptsLoading) return;
|
if (promptsLoaded || promptsLoading) return;
|
||||||
promptsLoading = true;
|
promptsLoading = true;
|
||||||
var container = document.getElementById('cms-prompt-editor');
|
var groups = [
|
||||||
|
['scribe', document.getElementById('cms-scribe-prompts'), ['scribe']],
|
||||||
|
['clinical', document.getElementById('cms-clinical-prompts'), ['clinical-text', 'clinical-image']],
|
||||||
|
['learning', document.getElementById('cms-learning-prompts'), ['learning-image']]
|
||||||
|
];
|
||||||
try {
|
try {
|
||||||
var data = await promptRequest('/api/admin/config/prompts');
|
var data = await promptRequest('/api/admin/config/prompts');
|
||||||
if (container) {
|
groups.forEach(function(group) {
|
||||||
container.replaceChildren();
|
if (!group[1]) return;
|
||||||
var prompts = (data.prompts || []).filter(function(p) { return p.editable === true; });
|
group[1].replaceChildren();
|
||||||
container.appendChild(createPromptFamilyEditor('all', prompts));
|
var prompts = (data.prompts || []).filter(function(p) { return p.editable === true && group[2].indexOf(p.family) !== -1; });
|
||||||
|
if (!prompts.length) {
|
||||||
|
group[1].textContent = 'No editable prompts available in this section.';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
group[1].appendChild(createPromptFamilyEditor(group[0], prompts));
|
||||||
|
});
|
||||||
promptsLoaded = true;
|
promptsLoaded = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (container) {
|
groups.forEach(function(group) {
|
||||||
container.textContent = 'Could not load prompts: ' + error.message + ' ';
|
if (!group[1]) return;
|
||||||
|
group[1].textContent = 'Could not load prompts: ' + error.message + ' ';
|
||||||
var retry = document.createElement('button');
|
var retry = document.createElement('button');
|
||||||
retry.type = 'button'; retry.className = 'btn-sm btn-ghost'; retry.textContent = 'Retry loading prompts';
|
retry.type = 'button'; retry.className = 'btn-sm btn-ghost'; retry.textContent = 'Retry loading prompts';
|
||||||
retry.onclick = loadPromptList;
|
retry.onclick = loadPromptList;
|
||||||
container.appendChild(retry);
|
group[1].appendChild(retry);
|
||||||
}
|
});
|
||||||
} finally { promptsLoading = false; }
|
} finally { promptsLoading = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,9 @@ async function browser(t, module, handler) {
|
||||||
await tick();
|
await tick();
|
||||||
return { window, document: window.document, calls, toasts };
|
return { window, document: window.document, calls, toasts };
|
||||||
}
|
}
|
||||||
const familyOf = ui => ui.document.getElementById('cms-prompt-editor');
|
const sectionOf = (ui, name) => ui.document.getElementById(name === 'scribe' ? 'cms-scribe-prompts' : name === 'clinical' ? 'cms-clinical-prompts' : 'cms-learning-prompts');
|
||||||
|
const sectionForPrompt = (ui, p) => sectionOf(ui, p.family === 'scribe' ? 'scribe' : p.family === 'learning-image' ? 'learning' : 'clinical');
|
||||||
|
const familyOf = ui => ui.document.getElementById('cms-scribe-prompts');
|
||||||
const selectOf = family => family.querySelector('.prompt-select');
|
const selectOf = family => family.querySelector('.prompt-select');
|
||||||
const draftOf = family => family.querySelector('.prompt-draft');
|
const draftOf = family => family.querySelector('.prompt-draft');
|
||||||
const statusOf = family => family.querySelector('.prompt-status');
|
const statusOf = family => family.querySelector('.prompt-status');
|
||||||
|
|
@ -63,18 +65,23 @@ async function choosePrompt(family, dbKey) {
|
||||||
}
|
}
|
||||||
async function clickAction(family, name) { actionOf(family, name).click(); await tick(); }
|
async function clickAction(family, name) { actionOf(family, name).click(); await tick(); }
|
||||||
|
|
||||||
test('single prompt editor lists the whole catalogue, displays inert exact text, and saves canonical dbKey + expectedRevision', async t => {
|
test('three prompt sections keep Scribe, Clinical and Learning separate with inert exact text and canonical saves', async t => {
|
||||||
const ui = await browser(t, 'admin', (url, options) => {
|
const ui = await browser(t, 'admin', (url, options) => {
|
||||||
if (options.method === 'PUT') return json({ success: true, value: JSON.parse(options.body).value, revision: 11 });
|
if (options.method === 'PUT') return json({ success: true, value: JSON.parse(options.body).value, revision: 11 });
|
||||||
});
|
});
|
||||||
const editor = familyOf(ui);
|
const scribe = sectionOf(ui, 'scribe');
|
||||||
assert.equal(selectOf(editor).options.length, 32, 'all families in one dropdown');
|
const clinical = sectionOf(ui, 'clinical');
|
||||||
|
const learning = sectionOf(ui, 'learning');
|
||||||
|
assert.equal(selectOf(scribe).options.length, 29, 'Scribe stays as its own section');
|
||||||
|
assert.equal(selectOf(clinical).options.length, 2, 'Clinical TEXT and IMAGE share the Clinical section');
|
||||||
|
assert.equal(selectOf(learning).options.length, 1, 'Learning has its own section');
|
||||||
assert.equal(ui.document.querySelector('script, img, [onerror]'), null);
|
assert.equal(ui.document.querySelector('script, img, [onerror]'), null);
|
||||||
assert.equal(selectOf(editor).value, catalogue[0].dbKey, 'first prompt selected by default');
|
assert.equal(selectOf(scribe).value, catalogue[0].dbKey, 'first prompt selected by default');
|
||||||
assert.equal(selectOf(editor).options[0].textContent, 'SCRIBE_0');
|
assert.equal(selectOf(scribe).options[0].textContent, 'SCRIBE_0');
|
||||||
assert.equal(draftOf(editor).value, unsafe);
|
assert.equal(draftOf(scribe).value, unsafe);
|
||||||
for (const name of ['save', 'history', 'view', 'restore', 'reset', 'baseline']) assert.ok(actionOf(editor, name));
|
for (const name of ['save', 'history', 'view', 'restore', 'reset', 'baseline']) assert.ok(actionOf(scribe, name));
|
||||||
for (const p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) {
|
for (const p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) {
|
||||||
|
const editor = sectionForPrompt(ui, p);
|
||||||
await choosePrompt(editor, p.dbKey);
|
await choosePrompt(editor, p.dbKey);
|
||||||
assert.equal(draftOf(editor).value, unsafe);
|
assert.equal(draftOf(editor).value, unsafe);
|
||||||
draftOf(editor).value = '\n' + unsafe + '\n';
|
draftOf(editor).value = '\n' + unsafe + '\n';
|
||||||
|
|
@ -85,8 +92,8 @@ test('single prompt editor lists the whole catalogue, displays inert exact text,
|
||||||
assert.deepEqual(call.body, { value: '\n' + unsafe + '\n', expectedRevision: p.revision });
|
assert.deepEqual(call.body, { value: '\n' + unsafe + '\n', expectedRevision: p.revision });
|
||||||
assert.match(statusOf(editor).textContent, /Saved revision 11/);
|
assert.match(statusOf(editor).textContent, /Saved revision 11/);
|
||||||
}
|
}
|
||||||
await choosePrompt(editor, catalogue[2].dbKey);
|
await choosePrompt(scribe, catalogue[2].dbKey);
|
||||||
assert.equal(draftOf(editor).value, unsafe, 'other prompts unchanged');
|
assert.equal(draftOf(scribe).value, unsafe, 'other prompts unchanged');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('history/view/restore/reset use exact per-key APIs for every family and never replace other drafts', async t => {
|
test('history/view/restore/reset use exact per-key APIs for every family and never replace other drafts', async t => {
|
||||||
|
|
@ -97,13 +104,13 @@ test('history/view/restore/reset use exact per-key APIs for every family and nev
|
||||||
if (url.endsWith('/restore')) return json({ success: true, value: unsafe, revision: 11 });
|
if (url.endsWith('/restore')) return json({ success: true, value: unsafe, revision: 11 });
|
||||||
if (url.endsWith('/reset')) return json({ success: true, value: 'new shipped default', revision: 12 });
|
if (url.endsWith('/reset')) return json({ success: true, value: 'new shipped default', revision: 12 });
|
||||||
});
|
});
|
||||||
const editor = familyOf(ui);
|
const scribe = sectionOf(ui, 'scribe');
|
||||||
await choosePrompt(editor, catalogue[1].dbKey);
|
await choosePrompt(scribe, catalogue[1].dbKey);
|
||||||
draftOf(editor).value = 'Unrelated unsaved Scribe draft';
|
draftOf(scribe).value = 'Unrelated unsaved Scribe draft';
|
||||||
await choosePrompt(editor, catalogue[0].dbKey);
|
await choosePrompt(scribe, catalogue[0].dbKey);
|
||||||
for (const p of [catalogue[0], ...catalogue.slice(-2)]) {
|
for (const p of [catalogue[0], ...catalogue.slice(-2)]) {
|
||||||
await choosePrompt(editor, p.dbKey);
|
const f = sectionForPrompt(ui, p);
|
||||||
const f = editor;
|
await choosePrompt(f, p.dbKey);
|
||||||
const base = '/api/admin/config/prompts/' + p.dbKey;
|
const base = '/api/admin/config/prompts/' + p.dbKey;
|
||||||
draftOf(f).value = 'Unsaved clinical/Scribe edits';
|
draftOf(f).value = 'Unsaved clinical/Scribe edits';
|
||||||
await clickAction(f, 'history');
|
await clickAction(f, 'history');
|
||||||
|
|
@ -126,8 +133,8 @@ test('history/view/restore/reset use exact per-key APIs for every family and nev
|
||||||
assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 11 });
|
assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 11 });
|
||||||
assert.equal(draftOf(f).value, 'new shipped default');
|
assert.equal(draftOf(f).value, 'new shipped default');
|
||||||
}
|
}
|
||||||
await choosePrompt(editor, catalogue[1].dbKey);
|
await choosePrompt(scribe, catalogue[1].dbKey);
|
||||||
assert.equal(draftOf(editor).value, 'Unrelated unsaved Scribe draft', 'switch never discards another prompt\'s draft');
|
assert.equal(draftOf(scribe).value, 'Unrelated unsaved Scribe draft', 'switch never discards another prompt\'s draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('409s preserve drafts; explicit review/rebase resolves conflicts without an automatic overwrite', async t => {
|
test('409s preserve drafts; explicit review/rebase resolves conflicts without an automatic overwrite', async t => {
|
||||||
|
|
@ -140,7 +147,7 @@ test('409s preserve drafts; explicit review/rebase resolves conflicts without an
|
||||||
return json({ success: true, value: JSON.parse(options.body).value, revision: 15 });
|
return json({ success: true, value: JSON.parse(options.body).value, revision: 15 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const f = familyOf(ui);
|
const f = sectionOf(ui, 'clinical');
|
||||||
await choosePrompt(f, 'clinical_assistant.system_behavior');
|
await choosePrompt(f, 'clinical_assistant.system_behavior');
|
||||||
draftOf(f).value = 'Keep this draft';
|
draftOf(f).value = 'Keep this draft';
|
||||||
await clickAction(f, 'save');
|
await clickAction(f, 'save');
|
||||||
|
|
@ -171,12 +178,12 @@ test('failed loads can retry; transport failures and in-flight saves preserve ne
|
||||||
return new Promise(resolve => { release = () => resolve(json({ success: true, revision: 11, value: JSON.parse(options.body).value })); });
|
return new Promise(resolve => { release = () => resolve(json({ success: true, revision: 11, value: JSON.parse(options.body).value })); });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const editor = familyOf(ui);
|
const scribe = sectionOf(ui, 'scribe');
|
||||||
assert.match(editor.textContent, /Unavailable catalogue/);
|
assert.match(scribe.textContent, /Unavailable catalogue/);
|
||||||
assert.ok(editor.querySelector('button'), 'retry control stays visible');
|
assert.ok(scribe.querySelector('button'), 'retry control stays visible');
|
||||||
catalogueFailure = false;
|
catalogueFailure = false;
|
||||||
editor.querySelector('button').click(); await tick();
|
scribe.querySelector('button').click(); await tick();
|
||||||
const f = editor;
|
const f = sectionOf(ui, 'clinical');
|
||||||
await choosePrompt(f, 'clinical_assistant.image_behavior');
|
await choosePrompt(f, 'clinical_assistant.image_behavior');
|
||||||
draftOf(f).value = 'Preserved draft';
|
draftOf(f).value = 'Preserved draft';
|
||||||
await clickAction(f, 'history');
|
await clickAction(f, 'history');
|
||||||
|
|
@ -203,7 +210,7 @@ test('empty history and failed revision viewing leave drafts intact and restore
|
||||||
if (url.endsWith('/history?limit=100')) return json({ success: true, revision: empty ? 0 : 4, revisions: empty ? [] : [{ id: 4, createdAt: 'now' }] });
|
if (url.endsWith('/history?limit=100')) return json({ success: true, revision: empty ? 0 : 4, revisions: empty ? [] : [{ id: 4, createdAt: 'now' }] });
|
||||||
if (url.endsWith('/revisions/4')) return json({ error: 'Revision unavailable' }, 404);
|
if (url.endsWith('/revisions/4')) return json({ error: 'Revision unavailable' }, 404);
|
||||||
});
|
});
|
||||||
const f = familyOf(ui);
|
const f = sectionOf(ui, 'scribe');
|
||||||
await choosePrompt(f, catalogue[1].dbKey);
|
await choosePrompt(f, catalogue[1].dbKey);
|
||||||
draftOf(f).value = 'Keep even when no history is available';
|
draftOf(f).value = 'Keep even when no history is available';
|
||||||
await clickAction(f, 'history');
|
await clickAction(f, 'history');
|
||||||
|
|
@ -458,7 +465,7 @@ test('assistant settings retries leave global prompt drafts/history and starter
|
||||||
const ui = await browser(t, 'admin', url => {
|
const ui = await browser(t, 'admin', url => {
|
||||||
if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503);
|
if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503);
|
||||||
});
|
});
|
||||||
const f = familyOf(ui);
|
const f = sectionOf(ui, 'scribe');
|
||||||
draftOf(f).value = 'Unsaved global prompt';
|
draftOf(f).value = 'Unsaved global prompt';
|
||||||
f.querySelector('.prompt-revision-text').value = 'Previously viewed revision';
|
f.querySelector('.prompt-revision-text').value = 'Previously viewed revision';
|
||||||
setting(ui, 'prompt-pool-snapshots').innerHTML = '<option value="7">Existing snapshot</option>';
|
setting(ui, 'prompt-pool-snapshots').innerHTML = '<option value="7">Existing snapshot</option>';
|
||||||
|
|
@ -499,41 +506,50 @@ test('admin tab already active at module init triggers loaders exactly once; gua
|
||||||
assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'catalogue loader catches up exactly once at init');
|
assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'catalogue loader catches up exactly once at init');
|
||||||
assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'CMS config and assistant settings each load once at init');
|
assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'CMS config and assistant settings each load once at init');
|
||||||
assert.equal(calls.filter(c => c.url === '/api/admin/config/models').length, 1, 'model list catches up at init');
|
assert.equal(calls.filter(c => c.url === '/api/admin/config/models').length, 1, 'model list catches up at init');
|
||||||
assert.equal(window.document.getElementById('cms-prompt-editor').querySelectorAll('.prompt-select option').length, 32);
|
assert.equal(window.document.getElementById('cms-scribe-prompts').querySelectorAll('.prompt-select option').length, 29);
|
||||||
window.document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
window.document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||||
await tick();
|
await tick();
|
||||||
assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'revisit never double-fires the catalogue loader');
|
assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'revisit never double-fires the catalogue loader');
|
||||||
assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'guarded assistant/CMS loaders never double-fire');
|
assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'guarded assistant/CMS loaders never double-fire');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('switching prompt keeps every unsaved draft in memory', async t => {
|
test('switching prompts within each section keeps every unsaved draft in memory', async t => {
|
||||||
const ui = await browser(t, 'admin');
|
const ui = await browser(t, 'admin');
|
||||||
const editor = familyOf(ui);
|
const scribe = sectionOf(ui, 'scribe');
|
||||||
await choosePrompt(editor, catalogue[0].dbKey);
|
const clinical = sectionOf(ui, 'clinical');
|
||||||
draftOf(editor).value = 'Scribe draft A';
|
const learning = sectionOf(ui, 'learning');
|
||||||
await choosePrompt(editor, catalogue[1].dbKey);
|
await choosePrompt(scribe, catalogue[0].dbKey);
|
||||||
assert.equal(draftOf(editor).value, unsafe);
|
draftOf(scribe).value = 'Scribe draft A';
|
||||||
draftOf(editor).value = 'Scribe draft B';
|
await choosePrompt(scribe, catalogue[1].dbKey);
|
||||||
await choosePrompt(editor, 'clinical_assistant.system_behavior');
|
assert.equal(draftOf(scribe).value, unsafe);
|
||||||
draftOf(editor).value = 'Text draft';
|
draftOf(scribe).value = 'Scribe draft B';
|
||||||
await choosePrompt(editor, 'clinical_assistant.image_behavior');
|
await choosePrompt(clinical, 'clinical_assistant.system_behavior');
|
||||||
draftOf(editor).value = 'Image draft';
|
draftOf(clinical).value = 'Text draft';
|
||||||
await choosePrompt(editor, catalogue[0].dbKey);
|
await choosePrompt(clinical, 'clinical_assistant.image_behavior');
|
||||||
assert.equal(draftOf(editor).value, 'Scribe draft A');
|
assert.equal(draftOf(clinical).value, unsafe);
|
||||||
await choosePrompt(editor, catalogue[1].dbKey);
|
draftOf(clinical).value = 'Image draft';
|
||||||
assert.equal(draftOf(editor).value, 'Scribe draft B');
|
await choosePrompt(learning, 'learning_hub.image_behavior');
|
||||||
await choosePrompt(editor, 'clinical_assistant.system_behavior');
|
draftOf(learning).value = 'Learning draft';
|
||||||
assert.equal(draftOf(editor).value, 'Text draft');
|
await choosePrompt(scribe, catalogue[0].dbKey);
|
||||||
await choosePrompt(editor, 'clinical_assistant.image_behavior');
|
assert.equal(draftOf(scribe).value, 'Scribe draft A');
|
||||||
assert.equal(draftOf(editor).value, 'Image draft');
|
await choosePrompt(scribe, catalogue[1].dbKey);
|
||||||
|
assert.equal(draftOf(scribe).value, 'Scribe draft B');
|
||||||
|
await choosePrompt(clinical, 'clinical_assistant.system_behavior');
|
||||||
|
assert.equal(draftOf(clinical).value, 'Text draft');
|
||||||
|
await choosePrompt(clinical, 'clinical_assistant.image_behavior');
|
||||||
|
assert.equal(draftOf(clinical).value, 'Image draft');
|
||||||
|
await choosePrompt(learning, 'learning_hub.image_behavior');
|
||||||
|
assert.equal(draftOf(learning).value, 'Learning draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('catalogue loading failure always reaches a visible retry state, never an eternal spinner', async t => {
|
test('catalogue loading failure always reaches a visible retry state, never an eternal spinner', async t => {
|
||||||
const ui = await browser(t, 'admin', url => {
|
const ui = await browser(t, 'admin', url => {
|
||||||
if (url === '/api/admin/config/prompts') return json({ error: 'Catalogue offline' }, 503);
|
if (url === '/api/admin/config/prompts') return json({ error: 'Catalogue offline' }, 503);
|
||||||
});
|
});
|
||||||
const f = familyOf(ui);
|
for (const name of ['scribe', 'clinical', 'learning']) {
|
||||||
|
const f = sectionOf(ui, name);
|
||||||
assert.match(f.textContent, /Catalogue offline/);
|
assert.match(f.textContent, /Catalogue offline/);
|
||||||
assert.ok(f.querySelector('button'), 'retry button rendered');
|
assert.ok(f.querySelector('button'), 'retry button rendered');
|
||||||
assert.doesNotMatch(f.textContent, /Loading/, 'no eternal spinner text remains');
|
assert.doesNotMatch(f.textContent, /Loading/, 'no eternal spinner text remains');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue