waybetter/packages/runtime/tests/stringPacksRich.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

143 lines
8.3 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
compilePack, conditionHolds, coverage, createStringEngine, driftReport, inventoryLookup, packInventory, sliceInventory, validateString,
checkPackShape, isPageInventory, type StringInventory,
} from '../src/stringPacks.ts';
const INVENTORY: StringInventory = {
format: 1, site: 'demo', catalog: 'web', base: 'en',
entries: {
t1: { kind: 'text', hash: 'h1' },
r2: { kind: 'rich', slots: 2, hash: 'h2' },
r0: { kind: 'rich', slots: 0, hash: 'h3' },
rx: { kind: 'rich', hash: 'h4' },
},
};
const pack = (strings: Record<string, string>, extra: object = {}) =>
({ format: 1, site: 'demo', catalog: 'web', language: 'eo', name: 'P', authors: ['a'], strings, ...extra });
test('rich values: element slots {e0}..{n-1}, each once, in any order; nothing else', () => {
const shape = inventoryLookup(INVENTORY)('r2')!;
assert.ok('parts' in validateString(shape, 'Legu {e1} antaŭ {e0}.'));
assert.ok('parts' in validateString(shape, 'Legu {e0}.'), 'the last slot may be left out');
assert.match((validateString(shape, 'Legu {e1}.') as { reason: string }).reason, /\{e0\}.*missing/);
assert.match((validateString(shape, '{e0} {e0} {e1}') as { reason: string }).reason, /once/);
assert.match((validateString(shape, '{e0} {e1} {e2}') as { reason: string }).reason, /not a parameter/);
assert.match((validateString(shape, '{e0} {e1, plural, other {x}}') as { reason: string }).reason, /not a plural/);
assert.match((validateString(shape, '<a>{e0}</a> {e1}') as { reason: string }).reason, /markup/);
assert.match((validateString(inventoryLookup(INVENTORY)('rx')!, 'x') as { reason: string }).reason, /not translatable yet/);
assert.ok('parts' in validateString(inventoryLookup(INVENTORY)('r0')!, 'Nur teksto'));
});
test('rich engine: pieces + element order, idempotent, original kept; a non-array original is left alone', () => {
const compiled = compilePack(pack({ r2: 'A {e1} B {e0} C', t1: 'Hejmo' }), INVENTORY);
assert.equal(compiled.report.valid, 2);
const engine = createStringEngine(INVENTORY, [compiled]);
const site = ['Read ', , ' now'];
const out = engine.resolve('r2', site) as { pieces: string[]; order: number[] };
assert.deepEqual(out, { pieces: ['A ', ' B ', ' C'], order: [1, 0] });
assert.equal(engine.resolve('r2', out), out, 'feeding the result back changes nothing');
assert.equal(engine.originals().get('r2'), site);
assert.equal(engine.resolve('r2', 'not an array'), 'not an array');
assert.deepEqual(engine.stats().mismatched, ['r2']);
});
test('page inventory carries rich slots; closed rich stays closed; coverage counts replaceable rich', () => {
const page = packInventory(INVENTORY);
assert.deepEqual(page.rich, { r2: 2, r0: 0 });
assert.ok(isPageInventory(JSON.parse(JSON.stringify(page))));
assert.ok(!isPageInventory({ ...page, rich: { r2: -1 } }));
assert.equal(inventoryLookup(page)('r2')!.slots, 2);
assert.equal(inventoryLookup(page)('rx')!.kind, 'rich');
assert.equal(inventoryLookup(page)('rx')!.slots, undefined);
assert.deepEqual(sliceInventory(page, ['r2']).rich, { r2: 2 });
const cover = coverage(INVENTORY, compilePack(pack({ r2: '{e0}{e1}' }), INVENTORY));
assert.deepEqual(cover.all, { translatable: 3, translated: 1, closed: 1 });
const drift = driftReport(INVENTORY, { r2: { kind: 'rich', slots: 3, hash: 'h2' } }, ['main']);
assert.deepEqual(drift.reshaped, ['r2']);
});
test('language condition: a pack with when.languages applies only on matching site languages', () => {
assert.equal(conditionHolds(undefined, undefined), true);
assert.equal(conditionHolds({ languages: ['en'] }, 'en'), true);
assert.equal(conditionHolds({ languages: ['en'] }, 'en-GB'), true);
assert.equal(conditionHolds({ languages: ['en'] }, 'EN-gb'), true);
assert.equal(conditionHolds({ languages: ['en'] }, 'tr'), false);
assert.equal(conditionHolds({ languages: ['en'] }, 'ent'), false);
assert.equal(conditionHolds({ languages: ['en'] }, undefined), false);
const compiled = compilePack(pack({ t1: 'Tweet' }, { when: { languages: ['en'] } }), INVENTORY);
const engine = createStringEngine(INVENTORY, [compiled]);
assert.equal(engine.resolve('t1', 'Post'), 'Post', 'no language yet: conditional pack waits');
engine.setLanguage('tr');
assert.equal(engine.resolve('t1', 'Gönderi'), 'Gönderi');
const english = createStringEngine(INVENTORY, [compiled]);
english.setLanguage('en-GB');
assert.equal(english.resolve('t1', 'Post'), 'Tweet');
assert.equal(english.resolve('t1', 'Tweet'), 'Tweet');
});
test('when is checked as part of the envelope', () => {
const errors = (when: unknown) => checkPackShape(pack({ t1: 'x' }, { when })).filter(issue => issue.level === 'error').length;
assert.equal(errors({ languages: ['en', 'en-GB'] }), 0);
assert.equal(errors({ languages: [] }), 1);
assert.equal(errors({ languages: ['<b>'] }), 1);
assert.equal(errors({ code: 'x' }), 1);
assert.equal(errors('en'), 1);
});
const REWRITE_INVENTORY: StringInventory = {
format: 1, site: 'demo', catalog: 'web', base: 'en',
entries: {
t1: { kind: 'text', hash: 'h' },
m1: { kind: 'message', params: ['name', 'count'], plural: ['count'], hash: 'h' },
r1: { kind: 'rich', slots: 2, hash: 'h' },
f1: { kind: 'format', hash: 'h' },
},
};
const WORDS = { Post: 'Tweet', Posts: 'Tweets', post: 'tweet', posts: 'tweets', Repost: 'Retweet' };
const wording = (extra: object = {}) => pack({}, { rewrite: { words: WORDS }, when: { languages: ['en'] }, ...extra });
test('rewrite: whole words in the own text of the site, carrying none of its sentences', () => {
const compiled = compilePack(wording(), REWRITE_INVENTORY);
assert.equal(compiled.values.size, 0);
const engine = createStringEngine(REWRITE_INVENTORY, [compiled]);
engine.setLanguage('en');
assert.equal(engine.resolve('t1', 'Post, Repost and Posts of the postal service; compost'), 'Tweet, Retweet and Tweets of the postal service; compost');
assert.equal(engine.resolve('t1', 'Nothing here'), 'Nothing here');
const site = (e: { name: string; count: number }) => `${e.name} has ${e.count} posts`;
const message = engine.resolve('m1', site) as (e: unknown) => string;
assert.equal(message({ name: 'Post Malone', count: 3 }), 'Post Malone has 3 tweets', 'a parameter value is never reworded');
assert.equal(message({ name: 'Ann', count: 1 }), 'Ann has 1 tweets');
assert.equal(engine.resolve('m1', message), message, 'feeding the result back changes nothing');
assert.equal(engine.originals().get('m1'), site);
const rich = engine.resolve('r1', ['Your post ', , ' was a Repost']) as unknown[];
assert.deepEqual(rich, ['Your tweet ', , ' was a Retweet']);
const same = ['No words'];
assert.equal(engine.resolve('r1', same), same, 'an unchanged value is the same object');
assert.equal(engine.resolve('f1', 'Post'), 'Post', 'formatters are never touched');
assert.equal(engine.resolve('zzz', 'Post'), 'Post', 'unknown keys are never touched');
});
test('rewrite: language condition, order with replacing packs, and its own validation', () => {
const compiled = compilePack(wording(), REWRITE_INVENTORY);
const turkish = createStringEngine(REWRITE_INVENTORY, [compiled]);
turkish.setLanguage('tr');
assert.equal(turkish.resolve('t1', 'Post'), 'Post');
// A later pack's own text wins and is not reworded; an earlier pack's text is.
const later = compilePack(pack({ t1: 'Post office' }, { language: 'en-x-b' }), REWRITE_INVENTORY);
const earlier = compilePack(pack({ t1: 'Post office' }, { language: 'en-x-a' }), REWRITE_INVENTORY);
const a = createStringEngine(REWRITE_INVENTORY, [compiled, later]);
a.setLanguage('en');
assert.equal(a.resolve('t1', 'Post'), 'Post office');
const b = createStringEngine(REWRITE_INVENTORY, [earlier, compiled]);
b.setLanguage('en');
assert.equal(b.resolve('t1', 'Post'), 'Tweet office');
const bad = (rewrite: unknown) => checkPackShape(wording({ rewrite })).filter(issue => issue.level === 'error').length;
assert.equal(bad({ words: { Post: 'Tweet' } }), 0);
assert.equal(bad({ words: {} }), 1);
assert.equal(bad({ words: { Post: '<b>Tweet</b>' } }), 1);
assert.equal(bad({ words: { 'two words': 'x' } }), 1);
assert.equal(bad({ words: { Post: 'Tweet' }, code: 1 }), 1);
assert.equal(bad({ words: Object.fromEntries(Array.from({ length: 65 }, (_, i) => [`w${i}`, 'x'])) }), 1);
});