feat(local-model): one button and a chooser window replace the welcome card

The read-only card is gone. The welcome screen has one button, "Run a local
model" ("Local model" once one is installed), and App Settings > Models has
one row; both open the same window.

The window follows `install.state`: choose a model (a radio group, with a
memory bar filled to what the selected model needs), confirm the sizes and
the folder, watch the download and the steps, and end on "Start a session".
Paused, failed, installed (running or stopped), Remove and "no model fits"
are screens of the same window. It polls the GET once a second while open.

Every dynamic string goes in through textContent, labels and sentences come
from the payload, no CLI id is compared, and every new string has a zh-CN
entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 22:16:38 +01:00
1 parent 3af94df4f6
commit 84d5c691ff
13 files changed
+4543 -1264

No files matched your search

+81 -38
View File
@@ -1,18 +1,22 @@
/**
* @fileoverview The "Model on this Mac" card and its App Settings group in Chinese (zh-CN).
* @fileoverview "Run a model on this Mac" in Chinese (zh-CN): the welcome button, the chooser
* window and the App Settings group.
*
* Three sources of text, all checked against the real dictionary in i18n.js:
* - the module's own strings, HARVESTED from the real local-model-ui.js (every literal it
* passes to `localModelT`), so a new string without an entry fails here instead of showing
* up English on one surface;
* - every sentence and label the SERVER may send (`LOCAL_MODEL_LABELS`, imported from the
* contract, so a label added there fails here until it has an entry);
* - the static heading and hint of the Settings group, read from the real index.html and run
* through the real DOM translator.
* - every sentence and label the SERVER may send: the recommendation (`LOCAL_MODEL_LABELS`),
* the tag of a model row (`LOCAL_MODEL_TAG_LABELS`) and the install
* (`LOCAL_MODEL_INSTALL_LABELS`), all imported from the contract, so a label added there
* fails here until it has an entry;
* - the static strings of the real index.html (the Settings group's heading and hint, the
* window's title), run through the real DOM translator.
*
* Both containers carry data-i18n-skip (model, harness and chip names are someone else's
* text), so nothing in them is reached by the DOM translator: these strings translate only
* through the module's own call, which is why they are checked through `t()` here.
* The window's body, the welcome button and the Settings row carry data-i18n-skip (model,
* coding agent and chip names are someone else's text), so nothing in them is reached by the
* DOM translator: these strings translate only through the module's own call, which is why
* they are checked through `t()` here.
*
* Beyond "it is Chinese now", each entry must keep its `{placeholder}` slots: a translation
* that drops one prints a sentence without its number.
@@ -24,7 +28,13 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js';
import {
LOCAL_MODEL_ERROR_LABELS,
LOCAL_MODEL_INSTALL_LABELS,
LOCAL_MODEL_LABELS,
LOCAL_MODEL_NONE_LABELS,
LOCAL_MODEL_TAG_LABELS,
} from '../src/types.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
@@ -39,11 +49,14 @@ type Api = {
/** Every literal the module hands to its translator call (Prettier may break after the paren). */
const FROM_MODULE = [...new Set([...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((m) => m[1]))];
/** Every text the server may send. */
const FROM_SERVER: string[] = Object.values(LOCAL_MODEL_LABELS);
/** Already in the dictionary for the outside-sessions block; reused here for a failed request. */
const SHARED = ['Could not reach the server.'];
const OWN = FROM_MODULE.filter((source) => !SHARED.includes(source));
/** Every text the server may send: the recommendation, the tag of a row, the install. */
const FROM_SERVER: string[] = [
...new Set([
...Object.values(LOCAL_MODEL_LABELS),
...Object.values(LOCAL_MODEL_TAG_LABELS),
...Object.values(LOCAL_MODEL_INSTALL_LABELS),
]),
];
/** A page with only the translator in it: enough for `t()`, and cheap to re-configure. */
const small = new JSDOM('<!doctype html><html><body></body></html>', {
@@ -66,13 +79,15 @@ afterAll(() => {
page.window.close();
});
/** The Settings group's static strings, as index.html ships them (English). */
/** The static strings of the page, as index.html ships them (English): the Settings group and the window's title. */
const GROUP_MARKUP = INDEX.match(
/<div class="set-group" id="localModelGroup">\s*<div class="set-group-head"><h4>([^<]+)<\/h4><\/div>\s*<p class="set-group-hint">([^<]+)<\/p>/
);
const STATIC = GROUP_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim()] : [];
const TITLE_MARKUP = INDEX.match(/<h3 id="localModelModalTitle">([^<]+)<\/h3>/);
const STATIC =
GROUP_MARKUP && TITLE_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim(), TITLE_MARKUP[1].trim()] : [];
const ALL = [...new Set([...OWN, ...FROM_SERVER, ...STATIC])];
const ALL = [...new Set([...FROM_MODULE, ...FROM_SERVER, ...STATIC])];
/** The `{slot}` names of a source text. */
const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) => m[1]);
@@ -80,25 +95,29 @@ const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m)
/** U+2013, U+2014 and U+2015, built from their code points so this file holds none of them. */
const LONG_DASH = new RegExp(`[${String.fromCharCode(0x2013, 0x2014, 0x2015)}]`);
/** What may stay Latin: the product, the platform and its vendors, the unit, and Token (the dictionary's own word). */
/**
* What may stay Latin: the product, the platform and its vendors, the units, Token (the
* dictionary's own word), and the proper names of the install (the runtime, the two tools a
* coding agent calls, the GPU and an API key).
*/
const leftover = (text: string) =>
text.replace(/\b(Codeman|macOS|Mac|GB|Apple|Intel|Token)\b/g, '').match(/[A-Za-z]+/g) ?? [];
text.replace(/\b(Codeman|macOS|Mac|GB|MB|Apple|Intel|Token|Ollama|GPU|API|git|Python)\b/g, '').match(/[A-Za-z]+/g) ??
[];
describe('the local model strings in zh-CN', () => {
it('harvests the module (the check is not vacuous)', () => {
expect(FROM_MODULE).toContain('Model on this Mac');
expect(FROM_MODULE).toContain('{model} ({id}) with {harness}');
expect(FROM_MODULE).toContain('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).');
expect(FROM_MODULE).toContain(
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'
);
// What the fit and speed labels measure, and the speed of an alternative.
expect(FROM_MODULE).toContain('Memory fit: needs {share}% of what this Mac can give a model');
expect(FROM_MODULE).toContain('No speed estimate for this model on this chip');
expect(FROM_MODULE).toContain('about {speed} tokens per second');
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(20);
expect(FROM_MODULE).toContain('Run a local model');
expect(FROM_MODULE).toContain('Local model');
expect(FROM_MODULE).toContain('About {need} of {budget} GB of memory');
expect(FROM_MODULE).toContain('Install on this Mac');
expect(FROM_MODULE).toContain('Download and install');
expect(FROM_MODULE).toContain('{speed} MB per second, {left}');
expect(FROM_MODULE).toContain('You can close this window. The download continues while Codeman is running.');
expect(FROM_MODULE).toContain('Start a session');
expect(FROM_MODULE).toContain('{model} is ready on this Mac.');
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(70);
// The only other way the module calls its translator is with payload text (a server
// sentence or label), so no string of its own can reach the page unharvested.
// sentence, or the tag of a row), so no string of its own can reach the page unharvested.
const code = MODULE.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
@@ -108,14 +127,23 @@ describe('the local model strings in zh-CN', () => {
// One more: the function's own declaration.
expect(calls).toBe(literalCalls + payloadCalls + 1);
expect(payloadCalls).toBe(2);
// The stage that only read is gone, with its sentence.
expect(MODULE).not.toContain('cannot download or install');
});
it('lists every server label, the none reasons included', () => {
it('lists every server label: the recommendation, the tags and the install', () => {
// A label added to the contract later fails below by its text, not here by a count.
expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18);
expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18 + 3 + 26);
for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text);
// Every error code has its sentence in the list.
for (const text of Object.values(LOCAL_MODEL_ERROR_LABELS)) expect(FROM_SERVER).toContain(text);
expect(FROM_SERVER).toContain('Best fit for this Mac');
expect(FROM_SERVER).toContain('Speed unknown');
expect(FROM_SERVER).toContain('Tested on a Mac like this');
expect(FROM_SERVER).toContain('Best fit');
expect(FROM_SERVER).toContain('Small model');
expect(FROM_SERVER).toContain(LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged);
expect(FROM_SERVER).toContain(LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen);
// The two other sentences of the `memory` reason and the two notes are not in the
// reason map, so they are named here.
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryContext);
@@ -124,6 +152,12 @@ describe('the local model strings in zh-CN', () => {
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteDiskDropped);
});
it('prints the developer tools sentence from one source', () => {
// Not sent by the server: the window prints it when `developerTools` is `missing`. The
// module's literal is the contract's text, so one dictionary entry serves both.
expect(FROM_MODULE).toContain(LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing);
});
it('never uses the translator slot {name} for a value of its own', () => {
for (const text of ALL) expect(slots(text), text).not.toContain('name');
});
@@ -166,9 +200,10 @@ describe('the local model strings in zh-CN', () => {
}
});
it('translates the Settings group heading and hint in the page, and leaves both containers alone', () => {
expect(STATIC).toHaveLength(2);
it('translates the static strings in the page, and leaves what the module writes alone', () => {
expect(STATIC).toHaveLength(3);
expect(STATIC[0]).toBe('Model on this Mac');
expect(STATIC[2]).toBe('Run a model on this Mac');
const translated = page.window.document.getElementById('localModelGroup')!;
const heading = translated.querySelector('.set-group-head h4')!.textContent!.trim();
const hint = translated.querySelector('.set-group-hint')!.textContent!.trim();
@@ -176,8 +211,16 @@ describe('the local model strings in zh-CN', () => {
expect(hint).not.toBe(STATIC[1]);
expect(hint).toBe(api.t(STATIC[1]));
expect(leftover(hint)).toEqual([]);
// Written only by the module, through its own translator call.
expect(INDEX).toMatch(/id="localModelCard"[^>]*data-i18n-skip/);
expect(INDEX).toMatch(/id="localModelSettings"[^>]*data-i18n-skip/);
const title = page.window.document.getElementById('localModelModalTitle')!.textContent!.trim();
expect(title).toBe('在此 Mac 上运行模型');
// The dialog's close button is named in Chinese too.
const close = page.window.document.querySelector('#localModelModal .modal-close')!;
expect(close.getAttribute('aria-label')).toBe('关闭');
// Written only by the module, through its own translator call. The button keeps the
// English label it ships with until the module shows it.
for (const id of ['welcomeLocalModelBtn', 'localModelSettings', 'localModelSettingsOpen', 'localModelWindowBody']) {
expect(INDEX).toMatch(new RegExp(`id="${id}"[^>]*data-i18n-skip`));
}
expect(page.window.document.getElementById('welcomeLocalModelLabel')!.textContent).toBe('Run a local model');
});
});
+349
View File
@@ -0,0 +1,349 @@
/**
* @fileoverview Real-Chromium coverage for the "Run a model on this Mac" window
* (src/web/public/local-model-ui.js): what DOM emulation cannot answer, because it lays nothing
* out.
*
* - the numbers of the model list line up: every row's speed and download size end on the
* same edge, under their captions, and every tag starts on the same edge;
* - a row is one line on a desktop and two lines under 560px, where nothing runs off the
* side and the buttons take the full width;
* - the memory bar is 10px high and filled to the selected model's share of its track, and
* the fill follows the selection;
* - the selected row carries the accent on its reading edge;
* - the welcome button is really gone while it is `hidden`, and the window paints above App
* Settings.
*
* The REAL index.html is served with its scripts taken out and the two shipping stylesheets
* inlined (mobile.css under the media query its `<link>` carries); the real module runs on a
* bare `CodemanApp` whose `_api` answers a fixture payload. The states themselves are pinned in
* JSDOM by test/local-model-ui.test.ts.
*
* Port: none (page.route on a fake origin, no server).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const dir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(dir, name), 'utf8');
const ORIGIN = 'http://app.test/';
/**
* index.html as shipped, minus every script, with styles.css and mobile.css inlined. The
* comments go first: some of them mention a script tag, which would otherwise open a match
* that swallows the markup up to the next real one.
*/
const MARKUP = read('index.html')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<script\b[\s\S]*?<\/script>/g, '');
const PAGE = MARKUP.replace(
'<link rel="stylesheet" href="styles.css">',
() => `<style>${read('styles.css')}</style>`
).replace(
'<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">',
() => `<style media="(max-width: 1023px)">${read('mobile.css')}</style>`
);
const RUNTIME = 185971671;
function row(modelId: string, modelLabel: string, downloadBytes: number, downloadGb: string, over: object = {}) {
return {
modelId,
modelLabel,
license: 'Apache-2.0',
paramsB: 9,
context: 131072,
cli: 'opencode',
cliLabel: 'OpenCode',
cliInstalled: true,
needBytes: 1,
needGb: '20.0',
downloadBytes,
downloadGb,
budgetShare: 0.5,
fit: 'good',
fitLabel: 'Good',
speed: 'ok',
tested: false,
needSource: 'estimated',
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', installed: true }],
installBytes: downloadBytes + RUNTIME,
installGb: downloadGb,
...over,
};
}
const STATUS = {
hardware: {
platform: 'darwin',
arch: 'arm64',
memBytes: 51539607552,
memGb: '51.5',
memClassGb: 48,
chip: 'Apple M5 Pro',
freeDiskBytes: 897000000000,
freeDiskGb: '897',
source: 'sysctl',
},
recommendation: {
verdict: 'ok',
headline: { text: 'Recommended for this Mac' },
notes: [],
pick: row('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 23105798956, '23.1', {
needGb: '36.0',
budgetShare: 0.957,
tokensPerSecond: 67.7,
tested: true,
tag: { kind: 'tested', label: 'Tested on a Mac like this' },
}),
alternatives: [
row('qwen3.8:27b', 'Qwen3.8 27B', 17741871939, '17.7', {
needGb: '30.6',
budgetShare: 0.814,
tokensPerSecond: 9.1,
tag: { kind: 'slow', label: 'Slow' },
}),
row('gpt-oss:20b', 'GPT-OSS 20B', 13793441427, '13.8', { needGb: '26.6', budgetShare: 0.709, speed: 'unknown' }),
row('qwen3.5:9b', 'Qwen3.5 9B', 6550825373, '6.6', {
needGb: '17.0',
budgetShare: 0.25,
tokensPerSecond: 28.6,
tag: { kind: 'small', label: 'Small model' },
}),
],
budgetBytes: 37580963840,
budgetGb: '37.6',
budgetSource: 'estimated',
},
catalogUpdated: '2026-11-06',
install: {
state: 'idle',
running: false,
developerTools: 'present',
runtime: { version: '0.40.15', installed: false, downloadBytes: RUNTIME, downloadGb: '0.2', unpackedGb: '0.6' },
folder: '/Users/sam/.codeman/local-model',
actions: ['install'],
},
};
type Box = { left: number; right: number; top: number; bottom: number; width: number; height: number };
/** What the page holds once the module ran on the stand-in class. */
interface PageApp {
_api: () => Promise<unknown>;
loadLocalModel(): Promise<void>;
openLocalModelWindow(): void;
}
type PageWindow = { CodemanApp: new () => PageApp; app: PageApp };
describe('the local model window (real Chromium)', () => {
let browser: Browser;
let p: Page;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
});
afterEach(async () => {
await p?.close();
});
it('runs on the real page with both stylesheets in it', () => {
expect(MARKUP).toContain('id="localModelModal"');
expect(MARKUP.includes('<script')).toBe(false);
expect(PAGE.includes('.lm-bar-fill {')).toBe(true);
expect(PAGE.includes('<style media="(max-width: 1023px)">')).toBe(true);
expect(PAGE.includes('href="styles.css"')).toBe(false);
});
/** The real page at a width, the module loaded, the answer in, the window closed. */
async function load(width: number, height = 900) {
p = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
// No motion: the bar is at its width as soon as it is painted, so nothing here waits on a clock.
await p.emulateMedia({ reducedMotion: 'reduce' });
await p.route('**/*', (route) =>
route.request().url() === ORIGIN
? route.fulfill({ contentType: 'text/html', body: PAGE })
: route.fulfill({ status: 404, body: '' })
);
await p.goto(ORIGIN);
await p.addScriptTag({ content: `function CodemanApp() {}\n${read('local-model-ui.js')}` });
await p.evaluate(async (status) => {
const w = window as unknown as PageWindow;
document.body.classList.add('app-loaded');
document.getElementById('welcomeOverlay')!.classList.add('visible');
w.app = new w.CodemanApp();
w.app._api = async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: status }) });
await w.app.loadLocalModel();
}, STATUS);
}
/** Two animation frames: whatever was just written is laid out and painted. */
const painted = () =>
p.evaluate(() => new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done()))));
async function open() {
await p.evaluate(() => (window as unknown as PageWindow).app.openLocalModelWindow());
await painted();
}
const boxes = (selector: string) =>
p.evaluate(
(sel) =>
[...document.querySelectorAll(sel)].map((el) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
}),
selector
) as Promise<Box[]>;
const same = (values: number[]) => Math.max(...values) - Math.min(...values) <= 0.6;
it('shows the welcome button only once the server answered for a Mac', async () => {
p = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
await p.route('**/*', (route) =>
route.request().url() === ORIGIN
? route.fulfill({ contentType: 'text/html', body: PAGE })
: route.fulfill({ status: 404, body: '' })
);
await p.goto(ORIGIN);
// As shipped: `hidden`, and really not laid out although the chip class is inline-flex.
expect(await p.evaluate(() => getComputedStyle(document.getElementById('welcomeLocalModelBtn')!).display)).toBe(
'none'
);
await p.close();
await load(1440);
const [button] = await boxes('#welcomeLocalModelBtn');
expect(button.width).toBeGreaterThan(100);
// One more chip of the launcher family: the same height as the chips above it would have.
expect(Math.round(button.height)).toBe(30);
expect(await p.textContent('#welcomeLocalModelLabel')).toBe('Run a local model');
});
it('lines the numbers up down the list on a desktop, one line a row', async () => {
await load(1440);
await open();
const rows = await boxes('.lm-models .lm-model');
expect(rows).toHaveLength(4);
const names = await boxes('.lm-model-name');
const tags = await boxes('.lm-model-tag');
const speeds = await boxes('.lm-model-speed');
const sizes = await boxes('.lm-model-size');
// Every number ends on the same edge, and every tag starts on the same edge.
expect(same(sizes.map((box) => box.right))).toBe(true);
expect(same(speeds.map((box) => box.right))).toBe(true);
expect(same(tags.map((box) => box.left))).toBe(true);
expect(same(names.map((box) => box.left))).toBe(true);
// The captions sit over their columns.
const [speedCaption] = await boxes('.lm-caption-speed');
const [sizeCaption] = await boxes('.lm-caption-size');
expect(same([speedCaption.right, speeds[0].right])).toBe(true);
expect(same([sizeCaption.right, sizes[0].right])).toBe(true);
// One line: name, tag, speed and size share a row's middle.
for (let i = 0; i < rows.length; i++) {
const middle = (box: Box) => (box.top + box.bottom) / 2;
expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3);
expect(Math.abs(middle(names[i]) - middle(speeds[i]))).toBeLessThan(3);
expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6);
expect(speeds[i].right).toBeLessThanOrEqual(sizes[i].left + 0.6);
}
// What is in a row lines up with the rest of the window on both edges.
const [figure] = await boxes('.lm-figure');
const [radio] = await boxes('.lm-radio');
const [bar] = await boxes('.lm-bar');
expect(same([figure.left, radio.left, bar.left])).toBe(true);
expect(same([bar.right, sizes[0].right])).toBe(true);
});
it('fills the bar to the selected model, and the fill follows the selection', async () => {
await load(1440);
await open();
const share = async () => {
const [track] = await boxes('.lm-bar');
const [fill] = await boxes('.lm-bar-fill');
expect(Math.round(track.height)).toBe(10);
expect(Math.round(fill.height)).toBe(10);
expect(same([track.left, fill.left])).toBe(true);
return fill.width / track.width;
};
// The pick is a tight fit: the bar is nearly full, and a sliver of the track still shows.
expect(await share()).toBeCloseTo(0.96, 2);
await p.click('.lm-models .lm-model:nth-child(4)');
await painted();
expect(await share()).toBeCloseTo(0.25, 2);
await p.keyboard.press('ArrowUp');
await painted();
expect(await share()).toBeCloseTo(0.71, 2);
// The fill moves over a short moment, and stands still for a reader who wants no motion.
const duration = () =>
p.evaluate(() => getComputedStyle(document.querySelector('.lm-bar-fill')!).transitionDuration);
expect(await duration()).toBe('0s');
await p.emulateMedia({ reducedMotion: 'no-preference' });
expect(await duration()).toBe('0.32s');
await p.emulateMedia({ reducedMotion: 'reduce' });
// The track is not the window's own colour, or the free part could not be seen.
const paint = await p.evaluate(() => {
const css = (sel: string, prop: string) => getComputedStyle(document.querySelector(sel)!).getPropertyValue(prop);
return {
track: css('.lm-bar', 'background-color'),
fill: css('.lm-bar-fill', 'background-color'),
fillImage: css('.lm-bar-fill', 'background-image'),
window: css('.local-model-window', 'background-color'),
edge: css('.lm-model-on', 'border-left-color'),
idleEdge: css('.lm-model:not(.lm-model-on)', 'border-left-color'),
};
});
expect(paint.track).not.toBe(paint.window);
expect(paint.track).not.toBe(paint.fill);
expect(paint.fillImage).toBe('none');
// The selected row carries the accent (the fill's colour) on its reading edge.
expect(paint.edge).toBe(paint.fill);
expect(paint.idleEdge).not.toBe(paint.fill);
});
it('stacks two lines a row under 560px, with nothing running off the side', async () => {
await load(390, 844);
await open();
const names = await boxes('.lm-model-name');
const tags = await boxes('.lm-model-tag');
const speeds = await boxes('.lm-model-speed');
const sizes = await boxes('.lm-model-size');
for (let i = 0; i < names.length; i++) {
// Name and tag, then speed and size.
expect(speeds[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
expect(sizes[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
expect(tags[i].top).toBeLessThan(names[i].bottom);
expect(same([names[i].left, speeds[i].left])).toBe(true);
}
expect(same(sizes.map((box) => box.right))).toBe(true);
// The window fills the phone, the bar and the button take its width, and nothing overflows.
const [win] = await boxes('.local-model-window');
expect(Math.round(win.width)).toBe(390);
expect(Math.round(win.height)).toBe(844);
const [bar] = await boxes('.lm-bar');
const [button] = await boxes('.lm-buttons button');
expect(same([bar.left, button.left])).toBe(true);
expect(same([bar.right, button.right])).toBe(true);
expect(button.height).toBeGreaterThanOrEqual(44);
const overflow = await p.evaluate(() => {
const body = document.getElementById('localModelWindowBody')!;
return body.scrollWidth - body.clientWidth;
});
expect(overflow).toBeLessThanOrEqual(0);
});
it('paints above App Settings when it is opened from there', async () => {
await load(1440);
await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active'));
await open();
const top = await p.evaluate(() => {
const box = document.querySelector('.local-model-window')!.getBoundingClientRect();
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 20);
return !!hit && !!hit.closest('#localModelModal');
});
expect(top).toBe(true);
});
});
+1912 -762
View File
File diff suppressed because it is too large. Load diff