.assistant-layout.history-collapsed set display:none on .assistant-history, which removes it from grid flow. The layout still declared three tracks (0 / minmax(0,1fr) / 330px), so the two remaining items shifted left: .assistant-main took the 0 track and #assistant-sources took the 1fr. Collapsing the sidebar made the chat vanish and rendered the citations in its place. The rail is now a zero-width, visibility:hidden grid item, so the columns stay where they belong. Also: inside the assistant workspace the topbar no longer repeats "AI Clinical Assistant" — the page is the assistant. It stays in the DOM, visually hidden, so screen readers still announce it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
164 lines
10 KiB
JavaScript
164 lines
10 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 read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
|
|
function workspace(t) {
|
|
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-rendering-owner' }, true), true);
|
|
window.marked = marked;
|
|
window.DOMPurify = require('dompurify')(window);
|
|
window.matchMedia = () => ({ matches: true });
|
|
const fetched = [];
|
|
const activated = [];
|
|
const events = [];
|
|
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
|
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
|
|
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
|
|
fetchAssistantStatus: async () => ({ success: true }),
|
|
fetchAssistantExamples: async () => ({ success: true, examples: [] }),
|
|
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
saveAssistantChat: async () => ({ success: true }),
|
|
fetch: async url => { fetched.push(url); return new Response(read('public/components/learning.html')); } };
|
|
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();
|
|
window.activateTab = tab => activated.push(tab);
|
|
window.document.addEventListener('tabChanged', e => events.push(e.detail.tab));
|
|
t.after(() => window.close());
|
|
return { context, document: window.document, window, fetched, activated, events };
|
|
}
|
|
|
|
test('assistant area is an OWUI-style three-column workspace with a slim go-back top bar', t => {
|
|
const app = workspace(t);
|
|
const layout = app.document.querySelector('.assistant-layout');
|
|
assert.ok(layout);
|
|
const columns = [...layout.children].map(el => el.className.split(' ')[0]);
|
|
assert.ok(columns.includes('assistant-history'), 'left history rail');
|
|
assert.ok(columns.includes('assistant-main'), 'center chat column');
|
|
assert.ok(columns.includes('assistant-side'), 'right image/sources column');
|
|
const left = app.document.querySelector('.assistant-history');
|
|
assert.ok(left.querySelector('#assistant-saved-chats'), 'saved chats live in the left rail');
|
|
assert.equal(left.querySelector('#btn-assistant-clear') !== null, true, 'New chat sits at the top of the rail');
|
|
assert.equal(left.firstElementChild.id, 'btn-assistant-drawer-close', 'the drawer leads with its close control');
|
|
const right = app.document.querySelector('.assistant-side');
|
|
assert.equal(right.querySelector('#assistant-visual-output'), null, 'no image display in the right column — sources only');
|
|
assert.ok(right.querySelector('#assistant-sources'), 'sources panel in the right column');
|
|
assert.equal(right.querySelector('#assistant-saved-chats'), null, 'saved chats moved out of the right column');
|
|
const topbar = app.document.querySelector('.assistant-topbar');
|
|
assert.ok(topbar);
|
|
assert.ok(app.document.querySelector('.assistant-topbar #btn-assistant-goback'), 'Go back is visible in the topbar');
|
|
assert.ok(app.document.querySelector('.assistant-history #btn-assistant-create-image'), 'Create image entry sits at the rail top');
|
|
const actions = app.document.querySelector('.assistant-toolbar-actions');
|
|
const buttons = [...actions.querySelectorAll('button')].map(b => b.id);
|
|
assert.deepEqual(buttons, ['btn-assistant-export-pdf', 'btn-assistant-download-chat', 'btn-assistant-takehome'], 'top bar keeps Export PDF, Download transcript and Patient take home');
|
|
const examples = app.document.querySelectorAll('.assistant-empty .assistant-examples button, [data-assistant-example]');
|
|
assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen');
|
|
});
|
|
|
|
test('opening the assistant replaces the main menu with the saved chats; Go back restores the app', async t => {
|
|
const app = workspace(t);
|
|
const c = app.context;
|
|
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
|
assert.ok(app.document.body.classList.contains('assistant-workspace'), 'fullscreen workspace class applied');
|
|
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } }));
|
|
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'leaving the assistant restores the app chrome');
|
|
app.document.body.classList.add('assistant-workspace');
|
|
app.document.getElementById('btn-assistant-goback').click();
|
|
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'Go back removes the workspace class');
|
|
});
|
|
|
|
test('Learning Hub is a top-level main-menu entry, not part of the assistant workspace', () => {
|
|
const indexHtml = read('public/index.html');
|
|
assert.match(indexHtml, /<button class="tab-btn" data-tab="learning">[\s\S]*?<span>Learning Hub<\/span>/, 'Learning Hub button in the sidebar');
|
|
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/, 'learning tab section exists');
|
|
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/, 'assistant tab is a clean standalone section');
|
|
});
|
|
|
|
test('go back leaves the assistant workspace for the last non-assistant tab', t => {
|
|
const app = workspace(t);
|
|
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'notes' } }));
|
|
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
|
app.document.getElementById('btn-assistant-goback').click();
|
|
assert.deepEqual(app.activated, ['notes'], 'returns to the previous main-menu tab');
|
|
});
|
|
|
|
test('the assistant tab keeps its clean structure next to the restored learning tab', () => {
|
|
const indexHtml = read('public/index.html');
|
|
assert.match(indexHtml, /<script src="\/vendor\/katex\/katex\.min\.js" defer><\/script>[\s\S]*<script src="\/vendor\/katex\/contrib\/mhchem\.min\.js" defer><\/script>/, 'mhchem loads right after katex');
|
|
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/);
|
|
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/);
|
|
});
|
|
|
|
test('desktop rail collapses and expands via the topbar toggle', t => {
|
|
const app = workspace(t);
|
|
const layout = app.document.querySelector('.assistant-layout');
|
|
const toggle = app.document.getElementById('btn-assistant-toggle-history');
|
|
assert.ok(toggle, 'history toggle present in the topbar');
|
|
toggle.click();
|
|
assert.ok(layout.classList.contains('history-collapsed'), 'rail collapses');
|
|
toggle.click();
|
|
assert.ok(!layout.classList.contains('history-collapsed'), 'rail expands');
|
|
});
|
|
|
|
test('mobile drawer rows carry no chat icons and keep the options menu', () => {
|
|
const css = read('public/css/assistant.css');
|
|
assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:100dvh;/.test(css), 'mobile layout locks the viewport');
|
|
assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile');
|
|
});
|
|
|
|
test('the saved-chats rail toggle is visible and states which way it goes', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const root = path.join(__dirname, '..');
|
|
const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8');
|
|
const toggle = html.split('\n').find(line => line.includes('btn-assistant-toggle-history'));
|
|
assert.ok(toggle, 'the toggle exists');
|
|
// fa-sidebar is Font Awesome PRO; on the Free 6.5.0 build this app loads it
|
|
// renders nothing, so the toggle was an invisible button.
|
|
assert.doesNotMatch(toggle, /fa-sidebar/, 'no Pro-only icon');
|
|
assert.match(toggle, /fa-table-columns/, 'a Font Awesome Free panel icon');
|
|
assert.match(toggle, /aria-expanded="true"/, 'exposes its state');
|
|
assert.match(toggle, /aria-controls="assistant-history"/);
|
|
|
|
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
|
|
assert.match(js, /syncHistoryToggle\(collapsed\)/, 'the toggle is synced when clicked');
|
|
assert.match(js, /syncHistoryToggle\(startCollapsed\)/, 'and on restore from localStorage');
|
|
assert.match(js, /Show saved chats/, 'the label flips so the collapsed state is escapable');
|
|
});
|
|
|
|
test('collapsing the rail does not hand the chat column to the citations panel', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
|
|
const collapsed = css.split('\n').find(l => l.startsWith('.assistant-layout.history-collapsed .assistant-history'));
|
|
assert.ok(collapsed, 'the collapsed rail rule exists');
|
|
// display:none removes the rail from grid flow, so .assistant-main inherits the
|
|
// 0 column and #assistant-sources takes the 1fr — the chat vanishes and the
|
|
// citations render in its place.
|
|
assert.doesNotMatch(collapsed, /display:\s*none/, 'the rail must stay a grid item');
|
|
assert.match(collapsed, /width:0/);
|
|
assert.match(collapsed, /visibility:hidden/);
|
|
const track = css.split('\n').find(l => l.startsWith('.assistant-layout.history-collapsed {'));
|
|
assert.match(track, /grid-template-columns:0 minmax\(0,1fr\) 330px/, 'three tracks for three items');
|
|
});
|
|
|
|
test('the assistant workspace does not repeat its own name in the topbar', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
|
|
const rule = css.split('\n').find(l => l.startsWith('body.assistant-workspace .assistant-topbar-title h2'));
|
|
assert.ok(rule, 'the title is hidden inside the workspace');
|
|
assert.match(rule, /clip:rect\(0 0 0 0\)/, 'visually hidden, still read by screen readers');
|
|
const html = fs.readFileSync(path.join(__dirname, '..', 'public/components/assistant.html'), 'utf8');
|
|
assert.match(html, /AI Clinical Assistant/, 'and still present in the DOM');
|
|
});
|