waybetter/packages/runtime/tests/themeDoctor.test.ts
CRT 4a2e3850df String packs: rich-text entries, language conditions and word rewriting; theme doctor report; translate-mode words
- Rich sentences (a link or button inside text) can be replaced by data: the inventory gives a rich entry `slots`, a pack value places the site's own elements with {e0}, {e1}, ... (each once, the last may be left out; no markup, plural or other parameters). The engine returns a pieces + element order template; the page inventory carries `rich` slot counts.
- Packs may carry `when: { languages }` (applies only while the site shows one of them; `en` matches `en-GB`) and `rewrite: { words }` (whole-word rewording of the site's own text, messages keep their parameters' values; no sentences of the site in the pack). The hook tells the engine the site's language; a rewording pack makes the delivery carry the whole inventory.
- Apps can ship built-in packs (BuiltInPacks) delivered before installed ones; syncStringPacks hands the tabs a delivery at start only when it changed.
- Translate mode words are a function the app supplies (asked at each draw) and gain a `plural` word; host validates `when` and `rewrite` envelopes.
- Theme doctor: every finding says where (token path or CSS rule), what and how to fix, as catalog keys wb.doctor.*; the Theme Builder shows the full report; the import hint is translated. Doctor words are in the 17 supported catalogs.
- waybetter strings check describes conditions and rewrites.
2026-10-01 14:19:36 +07:00

75 lines
4.2 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { DOCTOR_MESSAGES, themeDoctor } from '../src/themeDoctor.ts';
import { sanitizeSpec } from '../src/themeSpec.ts';
const ids = (raw: unknown) => themeDoctor(sanitizeSpec(raw)).map(advice => advice.id);
test('a theme made of tokens and component settings gets no advice', () => {
assert.deepEqual(ids({ name: 'Clean', colors: { bg: '#000000', text: '#ffffff', accent: '#1d9bf0' }, components: { button: { radius: 4 } } }), []);
});
test('free-form CSS is pointed at the lighter mechanism and its costs', () => {
const css = [
'a { border-radius: 0; }',
'.x { display: none; }',
'.y { color: #ff0000; }',
'.z { font-family: Comic Sans MS; }',
'div:has(img) { opacity: .9; }',
'* { box-sizing: border-box; }',
'.g { backdrop-filter: blur(8px); }',
'button:focus { outline: none; }',
'.m { transition: all .2s; }',
'.b { box-shadow: inset 1px 1px #fff, inset -1px -1px #000, inset 2px 2px #ddd; }',
].join('\n');
const found = ids({ name: 'Heavy', css });
for (const id of ['css-radius', 'css-hide', 'css-color', 'css-font', 'css-has', 'css-universal', 'css-blur', 'css-outline-none', 'css-motion', 'css-bevel']) {
assert.ok(found.includes(id), `${id} is reported`);
}
});
test('advice is a hint, never a change: the theme is left as it was', () => {
const theme = sanitizeSpec({ name: 'Keep', css: '.x { display: none; }' });
const before = JSON.stringify(theme);
themeDoctor(theme);
assert.equal(JSON.stringify(theme), before);
});
test('unreadable colors are named with their contrast', () => {
const advice = themeDoctor(sanitizeSpec({ name: 'Low', colors: { bg: '#000000', text: '#111111', accent: '#1d9bf0' } }));
const text = advice.find(item => item.id === 'contrast-text');
assert.ok(text, 'text contrast is reported');
assert.equal(text.kind, 'contrast');
assert.match(text.text, /\d\.\d\d:1/);
});
test('every finding says where it is (token or selector), what it found and how to fix it', () => {
const css = '.card { border-radius: 4px; }\n.row { border-radius: 2px; }\n.row { border-radius: 3px; }\n@media (min-width: 1px) { .nav a { display: none; } }\n* { margin: 0 }';
const theme = sanitizeSpec({ name: 'Where', css, colors: { bg: '#000000', text: '#111111', accent: '#1d9bf0' }, components: { postText: { fontSize: 13 } }, variants: { nav: 'top' } });
const found = themeDoctor(theme);
const radius = found.filter(item => item.id === 'css-radius').map(item => item.where);
assert.deepEqual(radius, ['.card { border-radius }', '.row { border-radius }'], 'one finding per rule, not per property');
assert.ok(found.some(item => item.id === 'css-hide' && item.where === '.nav a { display }'), 'a rule inside @media names its own selector');
assert.ok(found.some(item => item.id === 'css-universal'));
const token = found.find(item => item.id === 'contrast-text')!;
assert.equal(token.where, 'colors.text');
assert.equal(token.key, 'contrast');
assert.deepEqual(Object.keys(token.params), ['token', 'ratio']);
assert.match(token.text, /^The text color was adjusted/);
assert.ok(found.some(item => item.where === 'components.postText.fontSize' && item.params.size === '13'));
assert.ok(found.some(item => item.where === 'variants.nav' && item.params.position === 'top'));
for (const item of found) {
assert.ok(item.fix.length > 10, `${item.id} has a fix hint`);
assert.ok(`wb.doctor.${item.key}.found` in DOCTOR_MESSAGES && `wb.doctor.${item.key}.fix` in DOCTOR_MESSAGES, `${item.key} has catalog words`);
assert.ok(!/\{\w+\}/.test(item.text + item.fix), `${item.id}: every slot is filled`);
}
});
test('the doctor\'s words are plain text with only the slots its findings fill', () => {
for (const [key, text] of Object.entries(DOCTOR_MESSAGES)) {
assert.ok(!/[<>]/.test(text), `${key} has no markup`);
for (const slot of text.matchAll(/\{(\w+)\}/g)) assert.ok(['token', 'ratio', 'size', 'position'].includes(slot[1]!), `${key}: {${slot[1]}}`);
}
const ids = new Set(Object.keys(DOCTOR_MESSAGES).map(key => key.replace(/\.(found|fix)$/, '')));
for (const id of ids) assert.ok(`${id}.found` in DOCTOR_MESSAGES && `${id}.fix` in DOCTOR_MESSAGES, id);
});