pediatric-ai-scribe-v3/test/assistant-mobile.test.js
Daniel 050a7d5241 feat: citation quality tracking, and the SSO settings fit a phone
Citation quality
- A citation naming a source that never came back is never rendered as a
  link, so it appears as plain text and nobody learns it happened. It is now
  measured on the server, where the answer and the sources both exist, so it
  is seen whether or not a browser rendered it.
- Four Prometheus counters feed a Grafana dashboard (Ped-AI Citation
  Quality): answers, citations written, answers affected, and individual
  unresolved markers. Only answers with at least one unresolved citation are
  stored, with the question and the titles retrieval returned, so an operator
  can judge whether retrieval came back thin or the model over-cited. Rows
  expire after 30 days: this is a quality signal, not a transcript log.
- Both answer paths are covered. /chat/stream is normal; /chat is the
  fallback the client uses when streaming fails, so auditing only the first
  would have hidden exactly the answers produced under failure.
- The tracker is resolved on demand and allowed to be absent. Seven test
  files load this route with a hand-built list of permitted imports, and
  adding a hard dependency would mean editing all seven — and the eighth
  written later would break. Observation must never be able to fail an
  answer, so a missing module simply means no tracking.
- Metric registration reuses an already-registered counter, because this
  module can legitimately load twice in one process.

SSO settings on mobile
- Six rows were laid out inline: flex with a 160px label and an input that
  would not shrink, so on a phone the row was wider than the screen with
  nothing to scroll and no way to reach the rest. They use .admin-row now,
  which already stacks below 640px. Verified at 390px and 360px: nothing
  off-screen, no sideways overflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 23:44:43 +02:00

205 lines
13 KiB
JavaScript

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, /<div class="mobile-topbar" aria-hidden="true"><\/div>/, 'a real element, which iOS looks for at the top edge');
assert.match(index, /<meta name="theme-color" content="#ffffff">/);
assert.match(read('public/manifest.json'), /"theme_color": "#ffffff"/);
assert.doesNotMatch(css, /#main-app::before/);
assert.match(css, /:root\{ --mobile-bar:calc\(48px \+ env\(safe-area-inset-top, 0px\)\); \}/, 'the row reaches up behind the status bar');
assert.match(css, /\.btn-menu-toggle \{[^}]*top:calc\(6px \+ env\(safe-area-inset-top, 0px\)\)/, 'and the button sits below it');
assert.match(css, /\.sidebar\{ width:min\(85vw,320px\); left:calc\(-1 \* min\(85vw,320px\)\); padding:env\(safe-area-inset-top, 0px\) 0 0; \}/);
assert.match(read('public/css/assistant.css'), /\.assistant-sources-fab \{[^}]*top:calc\(8px \+ env\(safe-area-inset-top, 0px\)\)/);
});
test('no text control is small enough to make iOS zoom the page', () => {
const css = read('public/css/styles.css');
// iOS Safari zooms the whole page when a field under 16px takes focus and
// never zooms back, which is why fixed chrome (the menu button) ended up off
// screen after typing. 16px is the threshold.
const rule = css.slice(css.indexOf('/* iOS Safari zooms the whole page'));
assert.match(rule, /@media \(max-width:768px\) \{\s*\n\s*input:not\(\[type="checkbox"\]\)/);
assert.match(rule, /textarea, select \{ font-size:16px !important; \}/,
'it must beat per-component rules such as .demo-field select');
// Pinch-zoom stays available: the fix is the font size, not a locked viewport.
assert.doesNotMatch(read('public/index.html'), /user-scalable=no|maximum-scale=1/);
// The composer set 15px AFTER its own mobile override, so the base rule won.
const assistant = read('public/css/assistant.css');
assert.doesNotMatch(assistant, /#assistant-input \{ flex:1 1 auto;[^}]*font-size:15px/);
assert.match(assistant, /@media \(min-width:641px\) \{ #assistant-input \{ font-size:15px; \} \}/);
});
test('the SSO settings fit a phone instead of running off the side', () => {
const html = read('public/components/admin.html');
const sso = html.slice(html.indexOf('Single Sign-On'), html.indexOf('Email Templates'));
// These rows were laid out inline: flex with a 160px label and an input that
// would not shrink, so on a phone the row was wider than the screen with
// nothing to scroll and no way to reach the rest.
assert.doesNotMatch(sso, /min-width:160px/);
assert.doesNotMatch(sso, /display:flex;align-items:center;gap:12px;/);
assert.equal((sso.match(/class="admin-row"/g) || []).length, 6, 'every row uses the pattern that stacks');
assert.match(sso, /class="admin-row-label"/);
assert.match(sso, /class="admin-control"/);
// .admin-row stacks below 640px and its controls stop constraining width.
const css = read('public/css/styles.css');
assert.match(css, /\.admin-row \{ grid-template-columns:1fr; \}/);
assert.match(css, /\.admin-control \{ max-width:none; \}/);
});