pediatric-ai-scribe-v3/test/assistant-workspace-layout.test.js
Daniel 531996de1e feat: search finds what is inside a tab, and the menu head reads toggle, mark, search
Search now reaches sub-navigation, because people look for "bili" rather than
"Calculators". It reads whatever a loaded component exposes — data-calc,
data-subtab, data-section — so it covers every tab with sub-navigation instead of
one hard-coded list, and the relevant components are warmed when the palette
opens. Opening a result survives the component still loading, and reaching one
from the assistant navigates first and opens it after.

Menu head order is now show/hide menu, then the mark, then search. In the
collapsed rail every item is the same centred 52px box, so the icons finally
share one axis — the brand was a flex row with a gap and sat off-centre from the
buttons beneath it.

Settings, FAQ and Admin are no longer listed in the menu: they already have a
place in the account card, and listing them twice only made the tab list longer.
Their sections remain, and activateTab already tolerates a tab whose section
exists without a sidebar button.

The retry loops are named functions rather than IIFEs, which the module
entrypoint rules forbid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 05:16:05 +02:00

532 lines
33 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('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('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('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: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('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:100dvh;');
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: 100vh');
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('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/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.
assert.match(handler, /if \(wantsAssistant\) 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.ok(styles.includes('.' + cls + ' {'), 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'\)/);
const css = read('public/css/styles.css');
assert.match(css, /body\.assistant-preview \.account-card \{ display:none; \}/,
'a preview visitor has no account to show');
});
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');
}
assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: 100vh;/,
'the layout owns the full viewport');
});
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.
assert.match(css, /\.main-content\{[^}]*background-image:linear-gradient/,
'the tiled ground belongs to the shell');
assert.match(css, /\.main-content > \.tab-content\.active\{[^}]*border:1px solid var\(--g200\);border-radius:14px/,
'and every tab gets the same card edge');
// 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 an in-sidebar collapse control has
// nothing to collapse.
assert.match(mobile, /\.assistant-menu-toggle\{display:none !important;\}/);
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; \}/);
});
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.
assert.match(app, /SUB_NAV_SELECTOR = '\[data-calc\], \[data-subtab\], \[data-section\], \.calc-nav-pill'/);
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');
});
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');
}
});