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
532 lines
33 KiB
JavaScript
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');
|
|
}
|
|
});
|