const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8'); // On a phone the assistant showed TWO hamburgers, stacked, and neither did // anything useful: the app's opened a sidebar the assistant had hidden, and the // assistant's opened a rail that duplicated it. test('a phone has exactly one menu button', () => { const assistant = read('public/components/assistant.html'); const index = read('public/index.html'); assert.doesNotMatch(assistant, /btn-assistant-mobile-menu/, 'the assistant no longer adds a drawer button of its own'); assert.equal((index.match(/id="btn-menu-toggle"/g) || []).length, 1, 'the app sidebar button is the only one'); }); test('the app sidebar stays reachable inside the assistant on a phone', () => { const css = read('public/css/styles.css'); // Hiding it unconditionally is what made the app hamburger open nothing. assert.match(css, /@media\(min-width:769px\)\{ body\.assistant-workspace \.sidebar\{ display:none; \} \}/, 'the sidebar is only replaced by the assistant rail on desktop'); const hideBlock = css.slice(css.indexOf('body.assistant-workspace .announcement-banner'), css.indexOf('body.assistant-workspace .app-body')); assert.doesNotMatch(hideBlock, /\.sidebar/, 'nothing hides it unconditionally'); }); test('the collapse control closes the sheet on a phone rather than collapsing a rail', () => { const app = read('public/js/app.js'); const handler = app.slice(app.indexOf("event.target.closest('[data-menu-toggle]')"), app.indexOf("localStorage.setItem('ped_sidebar_collapsed'")); assert.match(handler, /if \(window\.innerWidth <= 768\)/); assert.match(handler, /closeMobileMenus\(\)/, 'it closes whichever menu is open'); const close = app.slice(app.indexOf('function closeMobileMenus()'), app.indexOf('function closeMobileMenus()') + 400); assert.match(close, /appMenu\.classList\.remove\('open'\)/, 'the workspace menu'); assert.match(close, /layout\.classList\.remove\('mobile-chats-open'\)/, 'and the chat history'); }); test('sources are reachable on a phone instead of deleted', () => { const css = read('public/css/assistant.css'); const mobile = css.slice(css.indexOf('@media (max-width: 640px)')); // display:none left every citation pointing at nothing. assert.doesNotMatch(mobile.slice(0, 2000), /\.assistant-side \{ display:none; \}/); assert.match(mobile, /body\.assistant-sources-open \.assistant-side \{ display:flex; height:min\(46vh,340px\); \}/, 'they open into a band of their own rather than covering the composer'); assert.doesNotMatch(mobile, /\.assistant-side \{ position:fixed;/, 'a fixed overlay sat on top of the question box'); const sources = mobile.slice(mobile.indexOf('.assistant-sources {')); assert.match(sources.slice(0, 200), /overflow-y:auto/, 'the list inside the band scrolls'); const js = read('public/js/clinicalAssistant.js'); assert.match(js, /button\.hidden = !\(citationsOn && count > 0\);/, 'the opener appears only when an answer has citations'); assert.match(js, /event\.target\.closest\('\.assistant-cite'\)/, 'and tapping a citation opens it'); // A sheet with no way out is a trap. assert.match(js, /if \(event\.key !== 'Escape'\) return;[\s\S]{0,160}assistant-sources-open/); assert.match(js, /if \(!document\.body\.classList\.contains\('assistant-sources-open'\)\) return;/, 'tapping away closes it'); }); test('the mobile menu is sized for a thumb', () => { const css = read('public/css/styles.css'); const mobile = css.slice(css.indexOf('/* ── Mobile menu ─')); assert.match(mobile, /\.sidebar\{ width:min\(85vw,320px\)/, 'a sheet the width of the chat drawer, not a narrow rail'); assert.match(mobile, /\.tab-btn\{ padding:11px 12px; font-size:14px/, 'rows are tappable'); // Plain, like the chat history: text rows and a grey current row. assert.match(mobile, /\.tab-btn i\{ display:none; \}/); assert.match(mobile, /\.tab-btn\.active\{ border-left:none; background:var\(--g100\)/); assert.match(mobile, /env\(safe-area-inset-bottom\)/, 'and clear of the home indicator'); }); test('the account menu escapes the collapsed rail instead of being clipped', () => { const css = read('public/css/styles.css'); // .sidebar has overflow:hidden and the collapsed rail is 52px, so a menu laid // out inside it was cut to a sliver — hiding Settings, FAQ and Log out behind // the avatar exactly when the rail is narrow. const sidebar = css.split('\n').find(l => l.startsWith('.sidebar{')); assert.match(sidebar, /overflow:hidden/, 'the rail does clip, so the menu must escape it'); assert.match(css, /body\.menu-hidden \.account-menu \{ position:fixed;/, 'the collapsed menu is positioned against the viewport, not the rail'); assert.match(css, /body\.menu-hidden \.account-menu \{[^}]*width:210px/, 'and keeps a readable width'); }); test('the account card sits at the same height in both rails', () => { const css = read('public/css/styles.css'); // The app column pads its bottom; the assistant rail did not, so its card // started lower than the app's. const app = css.split('\n').find(l => l.startsWith('.account-card {')); const assistant = css.split('\n').find(l => l.startsWith('.assistant-history .account-card {')); assert.match(app, /padding:8px;/); assert.match(assistant, /padding:8px 0;/, 'same vertical padding, so the cards align'); assert.match(css, /\.account-card-btn \{ min-height:44px; \}/, 'and the same row height in both'); }); test('a desktop collapse does not follow the user onto a phone', () => { const css = read('public/css/styles.css'); // menu-hidden is remembered in localStorage across viewports. Unscoped, its // 52px-rail rules hid .sidebar-tabs, the Assistant/Workspace switch and the // account name INSIDE the phone sheet — the menu opened onto a strip of icons // and every link looked broken. const start = css.indexOf('@media (min-width:769px) {\n /* Collapsing is a desktop idea'); assert.ok(start !== -1, 'the collapse rules are wrapped in a desktop-only query'); const block = css.slice(start, css.indexOf('\n}\n', start)); assert.match(block, /body\.menu-hidden \.sidebar-tabs/, 'including the one that hid the links'); assert.match(block, /body\.menu-hidden \.assistant-mode-switch/, 'and the one that hid Workspace'); assert.match(block, /body\.menu-hidden \.account-menu \{ position:fixed;/); // Nothing outside that query may still collapse the phone sheet. const outside = css.slice(0, start) + css.slice(css.indexOf('\n}\n', start)); assert.doesNotMatch(outside, /body\.menu-hidden \.sidebar \{ width:/); }); test('the Workspace pill works on a phone', () => { const app = read('public/js/app.js'); // The switch lives inside the sheet, so the view it changes is behind the // sheet until the sheet closes. assert.match(app, /\[data-assistant-mode\]'\)\)\) return;\s*\n\s*if \(window\.innerWidth <= 768\) closeMobileMenus\(\);/); const css = read('public/css/assistant.css'); // The launcher used to be display:none below 640px, so the pill toggled a // view that could never appear. assert.doesNotMatch(css, /body\.assistant-mode-workspace \.assistant-workspace-view \{ display:none; \}/); assert.match(css, /\.assistant-workspace-cards \{ grid-template-columns:1fr; \}/, 'and its cards stack into one column'); }); test('on a phone the menu follows the view: chat history in the assistant', () => { const app = read('public/js/app.js'); // The assistant used to open the workspace list, so a saved chat was // unreachable on a phone. const handler = app.slice(app.indexOf("e.target.closest('#btn-menu-toggle')"), app.indexOf("e.target.closest('#btn-sidebar-close')")); assert.match(handler, /window\.innerWidth <= 640 && document\.body\.classList\.contains\('assistant-workspace'\)/); assert.match(handler, /railLayout\.classList\.toggle\('mobile-chats-open'\)/, 'the assistant opens its chat drawer'); assert.match(handler, /else if \(sidebar\) \{\s*sidebar\.classList\.toggle\('open'\)/, 'everywhere else, the workspace list'); assert.match(app, /if \(window\.innerWidth <= 768\) closeMobileMenus\(\);\s*\n\s*return true;/, 'choosing a page closes it'); const css = read('public/css/assistant.css'); const mobile = css.slice(css.indexOf('@media (max-width: 640px)')); // The name card is pinned at the foot of both menus: only the list scrolls. assert.match(mobile, /\.assistant-history \{ position:fixed;[^}]*padding:env\(safe-area-inset-top, 0px\) 0 0; overflow:hidden;/, 'the drawer itself does not scroll, and clears the status bar'); assert.match(mobile, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto;/, 'the chat list does'); assert.match(mobile, /\.assistant-drawer-close \{ display:none !important; \}/, 'one close control, the « in the head row'); }); test('source cards fit the phone: long names wrap, nothing scrolls sideways or inside', () => { const css = read('public/css/assistant.css'); // An auto grid column grew to the longest unbroken file name, so every card // ran off the right edge of an iPhone. assert.match(css, /^\.assistant-sources \{[^}]*grid-template-columns:minmax\(0,1fr\);[^}]*overflow-x:hidden;/m); assert.match(css, /^\.assistant-source \{ min-width:0; overflow-wrap:anywhere; word-break:break-word;/m, 'long names break onto new lines, with the pre-15.4 Safari fallback'); const mobile = css.slice(css.indexOf('@media (max-width: 640px)')); assert.match(mobile, /\.assistant-source-excerpt \{ max-height:none; overflow:visible; \}/, 'no scroll box inside the scrolling list'); assert.match(mobile, /\.assistant-sources \{[^}]*overflow-x:hidden; touch-action:pan-y;/, 'and no sideways pan'); }); test('the phone top bar is solid behind the status bar too', () => { const index = read('public/index.html'); const css = read('public/css/styles.css'); // Scrolling an admin page on an iPhone showed text flowing at the top of the // screen: the page is drawn under the status bar (viewport-fit=cover) and the // theme colour was the removed header's blue. assert.match(index, /viewport-fit=cover/); assert.match(index, /