pediatric-ai-scribe-v3/test/assistant-workspace-layout.test.js
Daniel 41ceaca413 fix: actually merge assistant and workspace into one interface
The mode switch was toggling the `hidden` attribute on elements that each carry
their own `display:` rule. A class selector beats the UA stylesheet's
[hidden]{display:none}, so every one of those toggles silently did nothing —
which is why workspace mode still showed the chat, and why coming back never
restored the saved-chat list. Every affected element was in that state.

CSS now owns both modes from a single body class, in one block, and the JS does
nothing but set that class. No element.hidden juggling remains.

One accent. The assistant used --blue for user bubbles, focus rings, blockquotes
and example pills while citations, cards and the mode switch used --purple, so
the two halves read as two different apps. Purple was already dominant (23 uses
to 6) and is the assistant's identity, so blue is gone entirely. Source cards
pick up the same shape, shadow and hover accent as the workspace cards, and a
targeted citation highlights in the accent instead of a bare border.

Go back is removed from both the topbar and the rail: the Assistant/Workspace
switch is how you leave now, and the dead handler went with it.

Create image no longer takes a full-width gradient row competing with New chat;
it is a square icon button beside it with a tooltip and an aria-label.

Also guards the delegated document listeners against double registration.
initIfNeeded already prevented a second bind in production, but nothing stopped
a stray bindEvents() from double-firing every click — which is exactly what the
test harness hit, activating a tab twice from one click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
2026-09-10 01:41:53 +02:00

297 lines
19 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) {
// Real .tab-btn elements: the rail and cards mirror these, so a harness
// without them cannot show whether the mirroring works.
const tabs = '<button class="tab-btn" data-tab="notes"><i class="fas fa-note"></i><span>Notes</span></button>' +
'<button class="tab-btn" data-tab="calculators"><i class="fas fa-calculator"></i><span>Calculators</span></button>' +
'<button class="tab-btn hidden" data-tab="admin"><i class="fas fa-lock"></i><span>Admin</span></button>' +
'<button class="tab-btn" data-tab="settings"><i class="fas fa-gear"></i><span>Settings</span></button>';
const dom = new JSDOM(tabs + '<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);
// Go back is gone: the Assistant/Workspace switch is how you leave, so a
// second exit control in the topbar was one affordance too many.
assert.equal(app.document.querySelector('#btn-assistant-goback'), null, 'no Go back in the topbar');
assert.ok(app.document.querySelector('[data-assistant-mode="workspace"]'), 'the mode switch replaces it');
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', async t => {
const app = workspace(t);
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');
});
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, /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('a workspace entry leaves the assistant and lands back on the chat next time', t => {
const app = workspace(t);
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
app.document.querySelector('[data-assistant-mode="workspace"]').click();
assert.ok(app.document.body.classList.contains('assistant-mode-workspace'), 'workspace mode engaged');
const entry = app.document.querySelector('#assistant-workspace-links [data-assistant-workspace-tab="notes"]');
assert.ok(entry, 'the rail lists the app tabs');
// Cards are the clinical work surface; the rail keeps everything.
const cardTabs = [...app.document.querySelectorAll('#assistant-workspace-cards [data-assistant-workspace-tab]')]
.map(b => b.getAttribute('data-assistant-workspace-tab'));
assert.deepEqual(cardTabs, ['notes', 'calculators'], 'Settings is not a card; hidden Admin is nowhere');
assert.ok(app.document.querySelector('#assistant-workspace-links [data-assistant-workspace-tab="settings"]'),
'but Settings is still reachable from the rail');
assert.equal(app.document.querySelector('[data-assistant-workspace-tab="admin"]'), null,
'a hidden tab stays hidden in both');
entry.click();
assert.deepEqual(app.activated, ['notes'], 'opens that part of the app');
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'and leaves the assistant chrome');
// Returning must show the chat, not the empty rail the mode was left in.
assert.ok(!app.document.body.classList.contains('assistant-mode-workspace'),
'the assistant reopens on its chat rather than in workspace mode');
});
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-cards"/, 'cards carry the work tabs');
assert.match(html, /id="assistant-workspace-links"/, 'and the rail carries the full menu');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
const setMode = js.slice(js.indexOf('var setAssistantMode = function'), js.indexOf('var setAssistantMode = function') + 900);
// Every element this used to toggle carries its own `display:` rule, and a
// class selector beats the UA stylesheet's [hidden]{display:none} — so the
// .hidden juggling silently did nothing and the chat list never came back.
assert.doesNotMatch(setMode, /\.hidden = /, 'no element.hidden juggling');
assert.match(setMode, /classList\.toggle\('assistant-mode-workspace', workspace\)/, 'one class drives it');
assert.match(js, /NON_WORK_TABS = \['settings', 'admin', 'docs', 'faq'\]/);
assert.match(js, /cards && NON_WORK_TABS\.indexOf\(name\) === -1/, 'excluded from the cards only');
assert.match(js, /setAssistantMode\('assistant'\); \/\/ coming back/, 'leaving lands back on the chat');
const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8');
for (const rule of [
'body.assistant-mode-workspace #assistant-chat-view',
'body.assistant-mode-workspace .assistant-history .card',
'body.assistant-mode-workspace .assistant-side'
]) assert.ok(css.includes(rule), rule + ' is owned by CSS');
assert.match(css, /body\.assistant-mode-workspace \.assistant-layout \{ grid-template-columns:260px minmax\(0,1fr\); \}/,
'the sources track goes with the panel');
});
test('the assistant has one accent colour, not two', () => {
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
// Blue user bubbles and focus rings beside purple citations and cards made the
// two halves look like two different apps.
assert.doesNotMatch(css, /var\(--blue/, 'no leftover blue accent');
assert.match(css, /\.assistant-msg\.user \.assistant-bubble \{ white-space:pre-wrap; background:var\(--purple\)/);
assert.match(css, /\.assistant-source:hover \{ border-color:var\(--purple-light\); \}/, 'sources share the card language');
});
test('Go back is gone: Workspace is how you leave the assistant', () => {
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.doesNotMatch(html, /btn-assistant-goback/, 'no redundant Go back button');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
assert.doesNotMatch(js, /goBackToMainMenu/, 'and no dead handler left behind');
});
test('Create image sits beside New chat instead of taking its own row', () => {
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-actions/, 'the two share a row');
const button = html.split('\n').find(l => l.includes('btn-assistant-create-image'));
assert.match(button, /title="Create image"/, 'it is a tooltip now, not a label');
assert.match(button, /aria-label="Create image"/, 'still named for screen readers');
const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8');
const rule = css.split('\n').find(l => l.startsWith('.assistant-create-image {'));
assert.doesNotMatch(rule, /width:100%/, 'no longer a full-width banner');
assert.match(rule, /width:36px/);
});
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');
});