pediatric-ai-scribe-v3/test/assistant-workspace-layout.test.js
Daniel 750efc4106 feat: Assistant / Workspace mode switch in the rail, the way Home and Code swap the page
The rail's Workspace section was a collapsible extra sitting under the chats.
It is now the second of two modes, selected by a segmented switch at the top of
the rail:

- Assistant keeps the chat column, the saved-chat list, New chat and Create image.
- Workspace REPLACES the chat column and the Sources panel with the app's own
  menu — a card grid in the main column and the same list in the rail — so
  nothing from the assistant is left half-visible beside it.

Both the rail list and the cards are built from the app's real .tab-btn
elements, so a tab added, renamed or hidden in index.html follows automatically.

Learning Hub moved down from second place to sit immediately before Content
Manager, with the other content tools rather than above the clinical ones.

Also removed dead CSS for .assistant-view-switch, .assistant-learning-view and
#assistant-learning-root: that markup no longer exists anywhere in the app.

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

226 lines
14 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');
});
test('workspace mode does not override the mobile layout with a desktop header offset', () => {
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
// body.assistant-workspace .assistant-layout is both later in the file and more
// specific than the .assistant-layout rule inside @media (max-width:640px), so
// without a matching-specificity override phones inherit calc(100vh - 64px) —
// an offset for the app header that workspace mode has already hidden.
const override = css.indexOf('body.assistant-workspace .assistant-layout { height:100dvh;');
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - 64px)');
assert.ok(desktop > 0, 'the desktop workspace rule exists');
assert.ok(override > desktop, 'the mobile override comes after it, so it wins');
const rule = css.slice(override, css.indexOf('}', override));
assert.match(rule, /display:flex/, 'the mobile flex layout is restored');
assert.match(rule, /grid-template-rows:none/, 'and the grid rows do not linger on a flex box');
});
test('the rail switches between Assistant and Workspace like Home and Code', () => {
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');
assert.match(html, /assistant-mode-switch/, 'a two-pill switch sits at the top of the rail');
assert.match(html, /data-assistant-mode="assistant"/);
assert.match(html, /data-assistant-mode="workspace"/);
assert.match(html, /id="assistant-workspace-view"[^>]*hidden/, 'workspace view starts hidden');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
// Workspace must REPLACE the chat column and the sources panel, not sit beside them.
assert.match(js, /if \(chatView\) chatView\.hidden = workspace;/);
assert.match(js, /if \(workspaceView\) workspaceView\.hidden = !workspace;/);
assert.match(js, /if \(side\) side\.hidden = workspace \|\| !citationsOn;/, 'sources go too');
assert.match(js, /if \(chats\) chats\.hidden = workspace;/, 'and the saved-chat list');
});
test('Learning Hub sits with the content tools, not above the clinical ones', () => {
const fs = require('node:fs');
const path = require('node:path');
const index = fs.readFileSync(path.join(__dirname, '..', 'public/index.html'), 'utf8');
const order = [...index.matchAll(/data-tab="([a-z]+)"/g)].map(m => m[1]);
assert.ok(order.indexOf('learning') > order.indexOf('encounter'), 'moved down past the clinical tabs');
assert.equal(order[order.indexOf('learning') + 1], 'cms', 'and sits immediately before Content Manager');
});
test('the assistant rail carries the workspace menu without restating it', () => {
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');
assert.match(html, /assistant-rail-workspace/, 'the rail has a Workspace section');
assert.match(html, /id="assistant-rail-workspace"[^>]*hidden/, 'hidden until Workspace mode');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
// Built from the app's real tabs, so renaming or hiding one in index.html is
// reflected here automatically instead of drifting out of sync.
assert.match(js, /querySelectorAll\('\.tab-btn'\)/, 'sourced from the real tab list');
assert.match(js, /name === 'assistant'/, "the assistant does not link to itself");
assert.match(js, /tab\.classList\.contains\('hidden'\)/, 'hidden admin tabs stay hidden');
assert.match(js, /data-assistant-workspace-tab/, 'each entry activates its tab');
assert.match(js, /classList\.remove\('assistant-workspace'\)/, 'leaving the assistant restores the app chrome');
});