pediatric-ai-scribe-v3/test/assistant-workspace-layout.test.js
Daniel 025290d64a
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: retire Learning Hub
My Resources generates better slides than Learning Hub ever did — a typed deck
the model fills in, rendered by python-pptx with fit-to-slide text, figures, a
vision review and themes, against Learning Hub's markdown-through-pandoc — and
the articles and quizzes now live in the quiz app. Keeping a second, weaker
generator and a whole CMS beside it was not earning its maintenance.

Removed: three routers, the Learning Hub and Content Manager tabs, their
components and frontend modules, the five database tables, the WebDAV browser,
the content embedding column and its vector index.

Content was exported first — every article as markdown plus a full SQL dump of
all five tables — to ops-backups/learning-hub-export-*. That export is the
restore path; the migration's down() can recreate the shape but never the rows,
and says so.

Two things this simplifies rather than merely deletes:

generated_image_links existed only to record which published content an image
appeared in, and it was the sole reason a generated image could be read by
someone who did not make it. Images are now owner-only — the visibility rule is
one WHERE clause instead of a join across two tables and a published flag.

embeddings.js keeps the model discovery the admin panel uses and loses
searchSimilar and generateContentEmbedding, which queried a table that no longer
exists.

Kept deliberately: Nextcloud connect, disconnect and export, which are how a
generated note reaches a real filesystem and have nothing to do with Learning
Hub; learningRetrieval, which despite its name is the clinical corpus search My
Resources depends on; and the pandoc reference deck, still the fallback when the
python renderer fails, moved from assets/learning to assets/deck now that the
old name misleads.

Tests: four Learning-Hub-only files removed, and the individual cases inside
shared files that asserted its behaviour. Where a test used a Learning endpoint
only as a convenient example — the account-boundary token test, the policy
matrix — it now uses one that still exists, so the property it proves is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:14:20 +02:00

624 lines
40 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');
// No bar above the layout at all: the app page has only the blue header, so a
// second bar here made the assistant taller and left dead space on top.
assert.equal(app.document.querySelector('.assistant-topbar'), null, 'no assistant-only top bar');
// 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.equal(app.document.querySelector('.assistant-history #btn-assistant-create-image'), null,
'Create image moved into the + menu with the other conversation actions');
// The top bar is gone entirely: everything that acts on the conversation moved
// into the composer's + menu, so both views start at the same vertical
// position and switching cannot nudge the page up or down.
assert.equal(app.document.querySelector('.assistant-toolbar'), null, 'no top bar above the transcript');
const menu = app.document.querySelector('#assistant-plus-menu');
assert.ok(menu, 'the + menu exists');
const items = [...menu.querySelectorAll('button')].map(b => b.id);
assert.deepEqual(items, ['btn-assistant-create-image', 'btn-assistant-takehome', 'btn-assistant-export-pdf', 'btn-assistant-download-chat'],
'every action on the conversation lives in the + menu');
assert.ok(menu.querySelector('label[for="assistant-attach-input"]'), 'and attaching images with them');
assert.equal(menu.hidden, true, 'closed until asked for');
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('one menu toggle serves both the app sidebar and the assistant rail', () => {
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.match(markup, /data-menu-toggle/, where + ' has the toggle');
assert.match(markup, /title="Hide menu"/, where + ' names it Hide menu');
}
// The app used to carry a pin button AND a floating expand button, with a
// third control inside the assistant — three affordances for one idea.
assert.doesNotMatch(index, /btn-sidebar-pin|btn-sidebar-expand/, 'the old pin/expand pair is gone');
assert.doesNotMatch(rail, /btn-assistant-toggle-history/, 'and the assistant-only toggle with it');
const app = read('public/js/app.js');
assert.match(app, /classList\.toggle\('menu-hidden'\)/, 'one class drives both');
assert.match(app, /'Show menu' : 'Hide menu'/, 'the label flips so the collapsed state is escapable');
// Shared chrome lives in styles.css: the app page never loads assistant.css.
const css = read('public/css/styles.css');
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/);
// The toggle lives inside the box it collapses, so it must leave that box or
// there is no way to bring the menu back.
// The toggle is a head-row icon button, so it needs no position or margin of
// its own — styling it twice gave it a second size that fought the row.
assert.match(css, /\.menu-icon-btn \{[^}]*width:28px; height:28px/, 'one styling source');
assert.doesNotMatch(css, /^\.assistant-menu-toggle \{ display:flex/m, 'no duplicate box styling');
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/,
'and collapsed keeps it in the rail rather than floating over content');
// The collapsed rail keeps its icons; only the labelled lists go.
assert.match(css, /body\.menu-hidden \.sidebar-tabs,/, 'the tab list collapses');
assert.match(css, /body\.menu-hidden \.menu-brand h1,/, 'and the wordmark, leaving the mark');
assert.match(read('public/js/app.js'), /'Show menu' : 'Hide menu'/, 'and it says how to get back');
});
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:calc\(100dvh - var\(--mobile-bar, 0px\)\);/.test(css), 'mobile layout locks the viewport, below the menu row');
assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile');
});
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 and the app start at the same top edge', () => {
const rail = read('public/components/assistant.html');
const css = read('public/css/assistant.css');
// The assistant used to carry its own bar on top of the shared blue header,
// so switching modes changed the height of everything below it.
assert.doesNotMatch(rail, /assistant-topbar/, 'no second bar in the markup');
assert.doesNotMatch(css, /assistant-topbar/, 'and none left in the stylesheet');
assert.match(css, /\.assistant-history \{ display:grid; gap:10px; align-content:start; position:sticky; top:0; \}/,
'the rail sticks to the top of the layout, not below a bar');
// The status moved into the composer, next to the work it describes.
assert.match(rail, /assistant-composer-footer[\s\S]*?id="assistant-status"/,
'the ready/busy indicator lives in the composer now');
});
test('the menu toggle uses an icon that reads as a menu', () => {
for (const file of ['public/index.html', 'public/components/assistant.html']) {
const markup = read(file);
const button = markup.split('\n').find(l => l.includes('data-menu-toggle'));
assert.ok(button, file + ' has the toggle');
// fa-table-columns draws a split-pane glyph that says nothing about what the
// click does. Angles point the way the panel moves.
assert.doesNotMatch(button, /fa-table-columns/, file + ' avoids the split-pane glyph');
assert.match(button, /fa-angles-left/, file);
}
assert.match(read('public/css/assistant.css'), /\.assistant-menu-toggle\.is-collapsed i \{ transform:rotate\(180deg\); \}/,
'and it turns to show the direction it will act in');
// Collapsed, the toggle is the only control left, so it must stay reachable.
assert.match(read('public/css/styles.css'), /body\.menu-hidden \.sidebar \{ width:52px; \}/,
'collapsed narrows to an icon rail, so the toggle stays reachable in place');
});
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
// the mobile rule needs a matching-specificity override to win at all.
// Both now subtract the app header, which the assistant keeps rather than hides.
const override = css.indexOf('body.assistant-workspace .assistant-layout { height:calc(100dvh - var(--mobile-bar, 0px));');
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - var(--mobile-bar, 0px))');
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('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('the Assistant/Workspace switch is the same control in both places', () => {
const root = path.join(__dirname, '..');
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
// The app IS workspace mode. The switch appears in the app sidebar with
// Workspace highlighted and in the assistant rail with Assistant highlighted
// — the only difference between the two screens.
for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.match(markup, /class="assistant-mode-switch"/, where + ' has the switch');
assert.match(markup, /data-assistant-mode="assistant"/, where);
assert.match(markup, /data-assistant-mode="workspace"/, where);
}
const appActive = index.match(/class="active"[^>]*data-assistant-mode="(\w+)"/);
assert.equal(appActive && appActive[1], 'workspace', 'the app highlights Workspace');
const railActive = rail.match(/class="active"[^>]*data-assistant-mode="(\w+)"/);
assert.equal(railActive && railActive[1], 'assistant', 'the assistant highlights Assistant');
assert.doesNotMatch(index, /data-tab="assistant"/, 'the old AI Assistant nav entry is gone');
});
test('the switch navigates from the app and opens the launcher inside the assistant', () => {
const app = read('public/js/app.js');
const handler = app.slice(app.indexOf('// Assistant / Workspace switch'), app.indexOf('window.activateTab = activateTab;'));
// In the app you ARE the workspace, so only the Assistant pill goes anywhere.
// It switches in place rather than setting window.location, which reloads the
// document and would end a running recording.
assert.match(handler, /if \(!wantsAssistant\) return;/);
assert.match(handler, /if \(typeof window\.activateTab === 'function'\) window\.activateTab\('assistant'\);/);
assert.match(handler, /else window\.location\.href = '\/assistant';/);
assert.match(handler, /assistantShowWorkspaceLauncher\(!wantsAssistant\)/, 'in the assistant it swaps the view in place');
// The in-page mode machinery is gone, and with it the whole class of
// hidden-vs-display bugs that made the chat vanish and never come back.
const assistant = read('public/js/clinicalAssistant.js');
for (const dead of ['setAssistantMode', 'renderWorkspaceLinks', 'goBackToMainMenu']) {
assert.ok(!assistant.includes(dead), dead + ' is gone');
}
// Cards are clinical work surfaces only.
assert.match(assistant, /NON_WORK_TABS = \['settings', 'admin', 'docs', 'faq', 'cms'\]/,
'no account or content-management tabs among the cards');
const css = read('public/css/assistant.css');
assert.match(css, /body\.assistant-mode-workspace #assistant-chat-view \{ display:none; \}/,
'the launcher replaces the chat rather than stacking with it');
});
test('the assistant uses the app accent, so neither screen looks like a different product', () => {
const styles = read('public/css/styles.css');
assert.match(styles, /\.tab-btn\.active\{background:var\(--blue-light\);color:var\(--blue\)/,
'the app accent is blue');
assert.match(read('public/css/styles.css'), /\.assistant-mode-switch button\.active \{ background:white; color:var\(--blue\)/,
'the switch highlights in the app accent');
const css = read('public/css/assistant.css');
assert.match(css, /\.assistant-msg\.user \.assistant-bubble \{ white-space:pre-wrap; background:var\(--blue\)/);
// Citations keep their own purple chip so evidence stays distinct from
// interactive blue; nothing else may reintroduce a second accent.
const stray = css.split('\n').filter(l => l.includes('var(--purple') && !l.includes('assistant-cite'));
assert.deepEqual(stray, [], 'no second accent outside citation chips');
});
test('chrome rendered on the main app page is styled by the stylesheet that page loads', () => {
const index = read('public/index.html');
const styles = read('public/css/styles.css');
const assistantCss = read('public/css/assistant.css');
// assistant.css is pulled in by the assistant COMPONENT, so it is absent on
// "/". Anything the app page renders must be styled from styles.css or it
// shows up as an unstyled native control.
assert.doesNotMatch(index, /css\/assistant\.css/, 'the app page does not load assistant.css');
for (const cls of ['assistant-mode-switch', 'assistant-menu-toggle']) {
assert.match(index, new RegExp('class="[^"]*' + cls), cls + ' is rendered on the app page');
assert.match(styles, new RegExp('\\.' + cls + '[\\s{]'), cls + ' must be defined in styles.css');
// Defined in both files, the two copies drift and one silently wins.
assert.ok(!assistantCss.includes('.' + cls + ' {'), cls + ' must not be duplicated in assistant.css');
}
assert.match(styles, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'the collapse state is styled where the app can see it');
});
test('the model selector sits with send, and only when there is a choice', () => {
const html = read('public/components/assistant.html');
// ChatGPT/OWUI placement: the model belongs beside the send control in the
// composer, not in a bar above the transcript.
const right = html.slice(html.indexOf('assistant-composer-right'), html.indexOf('</form>'));
for (const id of ['assistant-model-pill', 'btn-assistant-send']) {
assert.ok(right.includes(id), id + ' is on the composer right');
}
assert.match(html, /id="assistant-model-pill"[^>]*hidden/, 'hidden until there is something to pick');
const js = read('public/js/clinicalAssistant.js');
assert.match(js, /One model means no choice: hide the selector entirely/);
assert.match(js, /var show = Array\.isArray\(allowed\) && allowed\.length > 1;/);
assert.match(js, /if \(pill\) pill\.hidden = !show;/, 'the pill follows the select');
assert.doesNotMatch(js, /assistant-model-label/, 'the old always-on model label is gone');
});
test('nothing sits above the transcript, so switching cannot shift the page', () => {
const css = read('public/css/assistant.css');
// Both views fill the same box from the same top edge. With a toolbar above
// one of them, switching moved everything down by its height.
assert.match(css, /#assistant-chat-view \{ display:grid; grid-template-rows:minmax\(0,1fr\) auto;/,
'chat is transcript + composer, nothing above');
assert.doesNotMatch(css, /^\.assistant-toolbar/m, 'no toolbar styling left');
assert.match(css, /\.assistant-workspace-view \{ flex:1 1 auto; min-height:0;/,
'the launcher fills the same box');
const html = read('public/components/assistant.html');
assert.doesNotMatch(html, /assistant-toolbar/, 'and no toolbar markup');
});
test('both modes share one surface, so switching changes only the content', () => {
const css = read('public/css/assistant.css');
// The tiled ground belongs to the panel itself, not to one view's empty state,
// so the chat and the workspace launcher sit on the same background.
assert.match(css, /\.assistant-main\.card \{\n\s*background-image:linear-gradient/,
'the tiles belong to the shared panel');
assert.doesNotMatch(css, /#assistant-chat-view:has[^\n]*\{\n\s*background-image/,
'not to one view only');
// The composer floats with room around it rather than pinned to the edge.
assert.match(css, /\.assistant-composer \{ margin:0 auto 20px; max-width:760px; width:calc\(100% - 40px\)/);
assert.match(css, /\.assistant-messages:not\(:has\(\.assistant-msg\)\) \{ display:flex; flex-direction:column; justify-content:center;/,
'an empty transcript centres rather than pinning the composer to the top');
});
test('one menu width, so the content does not shift sideways on switch', () => {
const styles = read('public/css/styles.css');
const css = read('public/css/assistant.css');
const sidebar = styles.split('\n').find(l => l.startsWith('.sidebar{'));
assert.match(sidebar, /width:210px/, 'the app sidebar is 210px');
// The assistant rail was 260px, so every switch moved the chat sideways.
assert.match(css, /grid-template-columns:210px minmax\(0,1fr\) 330px/, 'the rail matches it');
assert.doesNotMatch(css, /grid-template-columns:260px/, 'no 260px rail left');
});
test('Create image sits with the other conversation actions', () => {
const html = read('public/components/assistant.html');
const menu = html.slice(html.indexOf('assistant-plus-menu'), html.indexOf('</form>'));
// It acts on the conversation like take home and export, so it belongs in the
// + menu rather than as its own button on the rail.
assert.match(menu, /btn-assistant-create-image/, 'Create image is in the + menu');
const rail = html.slice(0, html.indexOf('assistant-main'));
assert.doesNotMatch(rail, /btn-assistant-create-image/, 'and not on the rail');
assert.doesNotMatch(read('public/css/assistant.css'), /^\.assistant-create-image \{/m, 'its button styling is gone');
});
test('one menu, ending the same way in both views', () => {
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
// The view decides what the menu LISTS; everything structural about the menu
// itself is identical, so switching never redraws the frame.
for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.match(markup, /class="account-card"/, where + ' ends with the account card');
assert.match(markup, /data-account-tab="settings"/, where + ' offers Settings');
assert.match(markup, /data-account-logout/, where + ' offers Log out');
assert.match(markup, /data-menu-toggle/, where + ' has the menu toggle');
assert.match(markup, /assistant-mode-switch/, where + ' has the view switch');
}
const app = read('public/js/app.js');
assert.match(app, /if \(user\.role === 'admin'/, 'Admin is offered only to admins');
assert.match(app, /logoutBtn = document\.getElementById\('btn-logout'\)/,
'log out reuses the existing flow rather than a second implementation');
// Reaching an app tab from the assistant has to leave the assistant.
assert.match(app, /if \(tab && window\.location\.pathname === '\/assistant'\)/);
});
test('there is no header bar; the brand heads the menu instead', () => {
const index = read('public/index.html');
const css = read('public/css/styles.css');
// Settings and Log out moved to the account card, so a whole band of chrome
// had nothing left to hold and became content.
assert.doesNotMatch(index, /<header class="app-header">/, 'no header element');
assert.doesNotMatch(css, /^\.app-header\{/m, 'and no header styling');
for (const [markup, where] of [[index, 'app menu'], [read('public/components/assistant.html'), 'assistant rail']]) {
assert.match(markup, /class="menu-brand"/, where + ' carries the brand');
}
// The canonical handlers stay so nothing that calls them breaks.
assert.match(index, /id="btn-logout" hidden/, 'logout handler retained, hidden');
assert.match(index, /id="btn-settings" hidden/);
// Nothing sits above the content, so no layout should still subtract a header.
for (const stale of [/calc\(100vh - 66px\)/, /calc\(100vh - 52px\)/, /calc\(100dvh - \d+px\)/]) {
assert.doesNotMatch(css, stale, 'no stale header offset in styles.css');
assert.doesNotMatch(read('public/css/assistant.css'), stale, 'none in assistant.css');
}
// The only offset left is the phone's menu row, a variable that is 0px on desktop.
assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: calc\(100vh - var\(--mobile-bar, 0px\)\);/,
'the layout owns the full viewport');
assert.match(css, /:root \{ --mobile-bar:0px; \}/, 'which is zero outside the phone breakpoint');
});
test('every view sits in the same shell, so opening a menu item is not a new app', () => {
const css = read('public/css/styles.css');
// Each tab used to be a plain white page while the assistant and the workspace
// launcher sat on a tiled card, so any menu click changed the whole face.
// The ground covers the whole page, menu included, from one origin: a second
// tile on .main-content would start 210px in and seam against the menu's.
assert.match(css, /\.app-body\{[^}]*background-image:linear-gradient/,
'the tiled ground belongs to the page');
assert.doesNotMatch(css, /\.main-content\{[^}]*background-image/, 'and only to the page');
// Flat menu on desktop, in both views; the phone sheet stays white.
assert.match(css, /@media\(min-width:769px\)\{ \.sidebar\{ background:transparent; border-right:none; \} \}/);
assert.match(css, /\.main-content > \.tab-content\.active\{[^}]*border:1px solid var\(--g200\);border-radius:14px/,
'and every tab gets the same card edge');
// ...and the same tiled-white surface as the assistant's chat panel, so a
// workspace page does not turn plain white on entry.
const tile = 'linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px)';
const card = css.slice(css.indexOf('.main-content > .tab-content.active{'), css.indexOf('}', css.indexOf('.main-content > .tab-content.active{')));
assert.ok(card.includes('background-color:white') && card.includes(tile) && card.includes('background-size:28px 28px'), 'page card is tiled white');
const panel = read('public/css/assistant.css');
// The top-level rule, not the phone override that also names .assistant-main.card.
const chat = (panel.match(/^\.assistant-main\.card \{[^}]*\}/m) || [''])[0];
assert.ok(chat.includes('background-color:white') && chat.includes(tile) && chat.includes('background-size:28px 28px'), 'the same fill as the chat panel');
// The assistant brings a full-bleed layout, so it replaces the shell card
// rather than nesting a second one inside it.
assert.match(css, /\.main-content > #assistant-tab\.active\{padding:0;border:none/);
assert.match(read('public/css/assistant.css'), /\.assistant-main\.card \{[\s\S]{0,220}?border:1px solid var\(--g200\); border-radius:14px/,
'the assistant panel carries the matching edge itself');
});
test('the new chrome is handled on phones', () => {
const css = read('public/css/styles.css');
const mobile = css.slice(css.indexOf('@media(max-width:768px)'));
// The sidebar slides in whole on mobile, so the collapse control closes it
// instead, and replaces the old "Menu" + X header as the one close control.
assert.doesNotMatch(mobile, /\.assistant-menu-toggle\{display:none/);
assert.match(mobile, /\.sidebar-header\{ display:none; \}/);
assert.match(mobile, /\.account-card\{position:sticky;bottom:0/, 'the account card stays reachable');
assert.match(mobile, /\.menu-search-panel\{width:100%/, 'the palette is full-bleed');
assert.match(mobile, /\.sidebar-nav\{flex:1 1 auto;min-height:0;\}/, 'the menu scrolls, the card does not float away');
});
test('the rail scrolls in both modes, not only after leaving for the app', () => {
const css = read('public/css/assistant.css');
// The app sidebar has always scrolled. The assistant rail had no overflow
// rule, so its list of every app tab ran off the bottom — and scrolling only
// appeared once you left for the app.
assert.match(css, /\.assistant-rail-workspace \{[^}]*flex:1 1 auto; min-height:0; overflow-y:auto/,
'the workspace list scrolls');
assert.match(css, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto/,
'and so does the saved-chat list');
// The rail itself is the flex column that makes those two scroll.
assert.match(css, /\.assistant-history \{ display:flex; flex-direction:column; height:100%; min-height:0; padding-bottom:8px; \}/,
'the rail is the flex column that makes those scroll, with the same bottom gap as the app');
});
test('the menu can always be reopened after it is hidden', () => {
const html = read('public/index.html');
const css = read('public/css/styles.css');
// This has broken twice by moving the toggle deeper and leaving a hide rule
// pointed at its old depth. So walk the real ancestor chain and prove no rule
// hides anything between the toggle and the sidebar root.
const toggleAt = html.indexOf('data-menu-toggle');
assert.ok(toggleAt > 0, 'the toggle exists');
const before = html.slice(0, toggleAt);
const ancestors = [];
for (const m of before.matchAll(/<(?:div|nav)\s+class="([^"]+)"/g)) ancestors.push(m[1].split(/\s+/));
// Innermost ancestors that are still open at the toggle.
const chain = ancestors.slice(-3).flat();
assert.ok(chain.includes('menu-head'), 'the toggle sits in the menu head');
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'collapsed is a rail, not a disappearance');
const hideRules = css.split('\n').filter(l => l.startsWith('body.menu-hidden') && l.includes('display:none'));
for (const rule of hideRules) {
const selector = rule.slice(0, rule.indexOf('{')).trim();
// A rule that hides a class on the toggle's own ancestor chain must exempt
// the branch the toggle is on.
for (const cls of ['menu-head', 'sidebar-nav']) {
if (new RegExp('\\.' + cls + '\\s*\\{|\\.' + cls + '\\s*,|\\.' + cls + '$').test(selector)) {
assert.fail('rule hides the toggle\'s ancestor .' + cls + ': ' + selector);
}
}
}
// And the exemptions that keep it reachable are actually present.
// Everything that keeps the rail usable while collapsed stays visible.
for (const kept of ['.menu-head', '.menu-brand', '.account-card']) {
assert.ok(!hideRules.some(r => r.slice(0, r.indexOf('{')).trim().endsWith(kept)),
kept + ' stays visible in the collapsed rail');
}
});
test('search reaches the things inside a tab, not just the tab', () => {
const app = read('public/js/app.js');
// People look for "bili", not "Calculators". Sub-navigation is read
// generically from whatever a loaded component exposes, so this covers every
// tab with sub-navigation rather than one hard-coded list.
// Listing, matching and opening all read one attribute list, so they cannot
// drift apart when a component's sub-navigation is added.
assert.match(app, /var SUB_NAV_ATTRS = \['data-calc', 'data-subtab', 'data-section', 'data-em'\];/);
assert.match(app, /var SUB_NAV_SELECTOR = SUB_NAV_ATTRS\.map\(function\(attr\) \{ return '\[' \+ attr \+ '\]'; \}\)\.join\(', '\) \+ ', \.calc-nav-pill';/);
assert.match(app, /var selector = SUB_NAV_ATTRS\.map\(function\(attr\) \{ return '\[' \+ attr \+ '="' \+ sub \+ '"\]'; \}\)\.join\(', '\);/);
assert.match(app, /function subItemsFor\(/);
assert.match(app, /if \(!tabEl \|\| !tabEl\.dataset\.loaded\) return \[\];/,
'an unloaded component exposes nothing, so it is skipped rather than guessed at');
assert.match(app, /function warmSearchableTabs\(/, 'and those components are warmed when the palette opens');
// Opening one has to survive the component still loading.
assert.match(app, /function openSubItem\(sub, tabName, attempts\)/);
assert.match(app, /if \(attempts > 0\) setTimeout/, 'it retries rather than clicking into an empty tab');
// Reached from the assistant, it has to navigate first and open after.
assert.match(app, /localStorage\.setItem\('ped_pending_sub', sub\)/);
const calculators = read('public/components/calculators.html');
assert.match(calculators, /data-calc="bili"/, 'the labels search reads are real');
// Bedside marks its emergency sections with data-em, so they are searchable too.
assert.match(read('public/components/bedside.html'), /data-em="sepsis"/);
assert.match(app, /\['calculators', 'wellvisit', 'peguide', 'vaxschedule', 'bedside'\]/);
});
test('the menu head reads toggle, mark, search — and account tabs are not listed twice', () => {
for (const file of ['public/index.html', 'public/components/assistant.html']) {
const markup = read(file);
const head = markup.slice(markup.indexOf('<div class="menu-head">'), markup.indexOf('assistant-mode-switch'));
const order = [...head.matchAll(/assistant-menu-toggle|menu-brand|data-menu-search/g)].map(m => m[0]);
assert.deepEqual(order, ['assistant-menu-toggle', 'menu-brand', 'data-menu-search'],
file + ': show/hide menu first, then the mark, then search');
}
// Settings, FAQ and Admin live in the account card; listing them in the menu
// too only made the tab list longer.
const index = read('public/index.html');
for (const tab of ['settings', 'faq', 'admin']) {
assert.doesNotMatch(index, new RegExp('class="tab-btn[^"]*" data-tab="' + tab + '"'), tab + ' is not in the menu list');
assert.match(index, new RegExp('id="' + tab + '-tab"'), tab + ' still has its section');
assert.match(index, new RegExp('data-account-tab="' + tab + '"|role="menuitem"'), tab + ' is reachable from the account card');
}
});
test('desktop: the assistant rail is the app sidebar, flush and flat', () => {
const css = read('public/css/assistant.css');
const styles = read('public/css/styles.css');
// Inside the content area's 14px padding the rail sat on the tiled ground, its
// name card 6px lower and further in, and the page 28px taller than the window.
const desk = css.slice(css.lastIndexOf('@media (min-width:961px) {'));
assert.match(desk, /body\.assistant-workspace \.main-content \{ padding:0; \}/);
assert.match(desk, /body\.assistant-workspace \.assistant-history \{ padding:0; gap:0; \}/,
'flush, and flat on the tiled ground like the workspace sidebar');
assert.match(desk, /body\.assistant-workspace \.assistant-side \{ margin-block:14px; height:auto; \}/, 'the panels carry the 14px');
// Same insets as the sidebar: .menu-head, .assistant-mode-switch, .account-card.
const rail = styles.slice(styles.lastIndexOf('@media (min-width:961px) {'));
assert.match(rail, /\.assistant-history \.menu-head \{ padding:10px 8px 8px 12px; \}/);
assert.match(styles, /^\.menu-head \{ display:flex; align-items:center; gap:4px; padding:10px 8px 8px 12px; \}/m);
assert.match(rail, /\.assistant-history \.assistant-mode-switch \{ margin:8px 12px 10px; \}/);
assert.match(styles, /\.assistant-mode-switch \{[^}]*margin:8px 12px 10px;/);
assert.match(rail, /\.assistant-history \.account-card \{ padding:8px; \}/);
assert.match(styles, /body\.assistant-workspace\.menu-hidden \.assistant-history \{[^}]*padding:0; \}/, 'collapsed adds no gap of its own');
});
test('the collapsed menu is a bar of its own, in both views', () => {
const css = read('public/css/styles.css');
// Flat on the tiled ground, the 52px strip had no edge: its icons looked
// pressed against the page card. Expanded stays flat; collapsed gets a bar.
const desk = css.slice(css.indexOf('@media (min-width:769px) {\n /* Collapsing is a desktop idea'));
const block = desk.slice(0, desk.indexOf('\n}\n'));
// Its own white column, no border line: the white against the tiles is the edge.
assert.match(block, /body\.menu-hidden \.sidebar,\s*body\.assistant-workspace\.menu-hidden \.assistant-history \{ background:white; \}/);
assert.match(css, /@media\(min-width:769px\)\{ \.sidebar\{ background:transparent; border-right:none; \} \}/, 'expanded stays flat');
});
test('search follows the view: Workspace searches the workspace even inside the assistant', () => {
const app = read('public/js/app.js');
const fn = app.slice(app.indexOf('function searchSources()'), app.indexOf("return { kind: 'tabs'"));
// Keyed on the URL, the workspace launcher (still at /assistant) searched chats.
assert.doesNotMatch(fn, /location\.pathname === '\/assistant'/);
assert.match(fn, /classList\.contains\('assistant-workspace'\)\s*&& !document\.body\.classList\.contains\('assistant-mode-workspace'\)/);
assert.match(fn, /if \(inChats && typeof window\.assistantSearchableChats === 'function'\)/);
});
test('collapsed workspace mode keeps the 52px strip instead of losing the menu', () => {
const css = read('public/css/assistant.css');
// This track said 0 and, loading after styles.css, won — the collapsed menu
// and its name card disappeared whenever the workspace launcher showed.
assert.doesNotMatch(css, /body\.assistant-mode-workspace\.menu-hidden \.assistant-layout \{ grid-template-columns:0 /);
assert.match(css, /body\.assistant-mode-workspace\.menu-hidden \.assistant-layout \{ grid-template-columns:52px minmax\(0,1fr\); \}/);
});
test('the create-image chat list never grows wider than its dialog', () => {
const js = read('public/js/clinicalAssistant.js');
// A native <select> list is as wide as its longest option; full chat titles
// pushed it far past the dialog.
assert.match(js, /document\.body\.appendChild\(modal\);\s*\n\s*fitSelectOptions\(modal\.querySelector\('#create-image-chat'\)\);/,
'labels are fitted once the dialog is laid out');
const fn = js.slice(js.indexOf('function fitSelectOptions(select)'), js.indexOf("window.addEventListener('resize', function () { fitSelectOptions"));
assert.match(fn, /var room = select\.clientWidth - OPTION_CHROME;/, 'fitted to the select, not a fixed character count');
assert.match(fn, /if \(room <= 0\) return;/, 'no layout, no change');
assert.match(fn, /opt\.title = full;/, 'the full title stays on hover');
assert.match(fn, /words\[i\]/, 'cuts on whole words');
assert.match(js, /window\.addEventListener\('resize', function \(\) \{ fitSelectOptions\(document\.getElementById\('create-image-chat'\)\); \}\);/);
});
test('the composer shows "Model", not the model id, and hides the status chatter', () => {
const html = read('public/components/assistant.html');
const css = read('public/css/assistant.css');
const js = read('public/js/clinicalAssistant.js');
// The select stays as the state holder, so the existing change listener still
// persists the choice; the button is only its compact face.
assert.match(html, /<select id="assistant-chat-model-select"[^>]*assistant-model-state/);
assert.match(html, /<button type="button" id="btn-assistant-model"[^>]*aria-haspopup="listbox"/);
assert.match(html, /<span>Model<\/span>/);
assert.match(css, /\.assistant-model-state \{ position:absolute; width:1px;/);
assert.match(css, /\.assistant-model-menu \{ position:absolute; right:0; bottom:calc\(100% \+ 6px\)/, 'opens upward, above the composer');
assert.match(js, /function syncModelMenu\(\)/);
assert.match(js, /changed\.initEvent\('change', true, false\);/, 'choosing goes through the select, so it saves the usual way');
// "Ready" / "Looking up sources..." / "saved" repeated what the send button
// already shows, and wrapped under the model control on a phone.
assert.match(css, /\.assistant-status, \.assistant-autosave-state \{ position:absolute; width:1px;/);
assert.match(html, /id="assistant-status"/, 'kept in the DOM so screen readers still hear progress');
});
test('the search palette lists destinations first, sub-items only once typing', () => {
const app = read('public/js/app.js');
const fn = app.slice(app.indexOf('function renderSearch(term)'), app.indexOf('function openSearch()'));
assert.match(fn, /if \(!needle\) return !item\.sub;/, 'nothing typed: main destinations only');
assert.match(fn, /return String\(item\.title \|\| ''\)\.toLowerCase\(\)\.indexOf\(needle\) !== -1;/);
});