mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
3af94df4f6
commit
84d5c691ff
13 files changed
+4543
-1264
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user