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, 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, '{e0} {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: [''] }), 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: 'Tweet' } }), 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); });