Files
iptvnator/tools/i18n/check-usage.test.mjs
T
4grayandClaude Opus 5.5 6e18983400 fix(i18n): translate hard-coded labels, snackbars and missing keys (#1867)
* fix(i18n): translate hard-coded labels, snackbars and missing keys

Catalog screens, snackbars and external player messages showed English
in every locale, and seven keys used in code were missing from en.json,
so ngx-translate rendered them raw.

- Catalog: "All items", item and channel counts, channel sort menus and
  tooltips, unnamed-category and empty-category labels, LIVE/PAUSED
  badges and the Xtream global search summary are translated. The Xtream
  and Stalker stores no longer bake an English name into the every-item
  sentinel; the facades return a null title and the view translates it.
- Snackbars: Xtream/Stalker request failures, the 413 upload error,
  backup export/import results and the category visibility failure use
  keys; every "Close" action uses CLOSE.
- External player: the main process sends an error code instead of an
  English sentence (player-error event, session errorCode, and a tag in
  rejected launch errors); the renderer, dock and VOD primary button
  translate it. The external player info dialog is translated.
- Adds the seven missing keys and 43 new ones, translated in all 18
  locales; seven legitimately identical values are baselined.
- tools/i18n/check-usage.mjs fails on keys used in code but missing from
  en.json; it runs in i18n:check (and so in CI through i18n:validate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): translate catalog counts in the template and skip inline template comments

- The category subtitle hands a key to the translate pipe instead of
  caching translate.instant(), so a cold start re-renders it once the
  language file loads.
- The Xtream live root count uses the singular/plural item keys, so one
  result no longer reads "1 channels".
- check-usage.mjs strips HTML comments inside inline templates of
  TypeScript files, so a commented-out key no longer fails the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): check keys in parenthesised translate pipe operands

`(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that
neither precede the pipe directly nor contain a dot, so the usage check
missed them. It now reads the ternary and fallback branches of a
parenthesised operand; a literal compared in the condition is not read
as a key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): expect the translated external player failure in the dock

A launch failure without an error code now shows the translated generic
status in the playback dock, with the raw main-process detail as its
tooltip. The ClearKey DASH flow asserted the raw English text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep the IPC error tag out of the stored session detail

A tagged launch failure reached ExternalPlayerSession.error unchanged, so
the dock tooltip showed "[iptvnator:external-player:start-failed]". The
registry now strips the tag when it stores the detail; the rejected IPC
error keeps it for the renderer to read the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web): expect translated portal request failure toasts

#1861's new resolved-failure specs asserted the English toast text; the
toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check
the key and its message/status params.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): read only returned branches of a grouped translate operand

A literal at the start of a condition, as in
`('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for
a translation key, so a valid template could fail the usage check. A
grouped literal now counts only when it ends its operand (end of group,
`:`, `||` or `??`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:02:21 +02:00

233 lines
7.1 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, test } from 'node:test';
import { findKeyReferences, run, stripComments } from './check-usage.mjs';
const EN = {
CLOSE: 'Close',
PORTALS: {
ITEMS: 'items',
DIALOG: { TITLE: 'Title', MESSAGE: 'Message' },
},
};
const NAMESPACES = new Set(['PORTALS']);
let root;
beforeEach(() => {
root = mkdtempSync(path.join(os.tmpdir(), 'i18n-usage-'));
writeFileSync(path.join(root, 'en.json'), JSON.stringify(EN));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function writeSource(relativePath, content) {
const filePath = path.join(root, 'src', relativePath);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
}
function check() {
const lines = [];
const code = run({
repoRoot: root,
enPath: path.join(root, 'en.json'),
sourceRoots: ['src'],
log: (line) => lines.push(line),
});
return { code, output: lines.join('\n') };
}
function keys(source, kind = 'ts') {
return findKeyReferences(source, kind, NAMESPACES).map(
({ key, kind: referenceKind }) => `${referenceKind}:${key}`
);
}
test('fails on a piped key that en.json lacks and names its location', () => {
writeSource(
'view.component.html',
`<p>{{ 'CLOSE' | translate }}</p>\n<p>{{ 'PORTALS.MISSING' | translate }}</p>\n`
);
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /^FAIL usage: 1 key\(s\)/m);
assert.match(output, /PORTALS\.MISSING {2}src\/view\.component\.html:2$/m);
});
test('passes when every referenced key exists', () => {
writeSource(
'view.ts',
`this.translate.instant('PORTALS.ITEMS');\nconst key = 'PORTALS.DIALOG';\n`
);
const { code, output } = check();
assert.equal(code, 0);
assert.match(output, /^ok usage: 1 files, 0 keys missing/m);
});
test('reads the first argument of translate calls, even across lines', () => {
assert.deepEqual(
keys(`this.translateService.instant(\n 'GHOST',\n { n: 1 }\n);`),
['leaf:GHOST']
);
assert.deepEqual(keys(`translateWithFallback('CLOSE', 'x')`), [
'leaf:CLOSE',
]);
assert.deepEqual(keys(`this.translate.get('CLOSE')`), ['leaf:CLOSE']);
assert.deepEqual(keys(`marker('CLOSE')`), ['leaf:CLOSE']);
});
test('reads the branches of a parenthesised pipe operand', () => {
assert.deepEqual(
keys(
"{{ (expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate }}",
'html'
),
['leaf:SHOW_LESS', 'leaf:SHOW_MORE']
);
assert.deepEqual(
keys("{{ (label() || 'CLOSE') | translate: { n: 1 } }}", 'html'),
['leaf:CLOSE']
);
});
test('does not read a literal compared in the condition as a key', () => {
assert.deepEqual(
keys(
"{{ (status() === 'ERROR' ? 'CLOSE' : other) | translate }}",
'html'
),
['leaf:CLOSE']
);
assert.deepEqual(
keys(
"{{ ('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate }}",
'html'
),
['leaf:CLOSE', 'leaf:CLOSE']
);
assert.deepEqual(
keys("{{ ('LIVE' ? 'CLOSE' : other) | translate }}", 'html'),
['leaf:CLOSE']
);
assert.deepEqual(
keys("{{ ('CLOSE' || fallback()) | translate }}", 'html'),
['leaf:CLOSE']
);
});
test('fails on a missing key in a parenthesised pipe operand', () => {
writeSource(
'hero.component.html',
"<button>{{ (open() ? 'CLOSE' : 'SHOW_MORE') | translate }}</button>\n"
);
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /SHOW_MORE {2}src\/hero\.component\.html:1$/m);
assert.doesNotMatch(output, /CLOSE {2}/);
});
test('ignores get() and similar calls on receivers that do not translate', () => {
assert.deepEqual(keys(`params.get('ID'); map.get('NAME');`), []);
});
test('a translate call must name a string, not a group of keys', () => {
writeSource('view.ts', `this.translate.instant('PORTALS.DIALOG');\n`);
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /PORTALS\.DIALOG {2}src\/view\.ts:1$/m);
});
test('a constant may name a group of keys that code completes', () => {
writeSource(
'actions.ts',
"const dialogKey = 'PORTALS.DIALOG';\nthis.translate.instant(`${dialogKey}.TITLE`);\n"
);
assert.equal(check().code, 0);
});
test('checks dotted literals in known namespaces outside translate calls', () => {
writeSource(
'keys.ts',
`export const KEYS = { a: 'PORTALS.ITEMS', b: 'PORTALS.GONE' };\n`
);
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /PORTALS\.GONE {2}src\/keys\.ts:1$/m);
assert.doesNotMatch(output, /PORTALS\.ITEMS/);
});
test('ignores dotted literals outside the en.json namespaces', () => {
assert.deepEqual(keys(`const codec = 'H.264'; const id = 'DB.QUERY';`), []);
});
test('a template literal prefix must name a group of keys', () => {
assert.deepEqual(keys('this.t(`PORTALS.DIALOG.${name}`)'), [
'prefix:PORTALS.DIALOG',
]);
writeSource('view.ts', 'this.translate.instant(`PORTALS.NOPE.${name}`);\n');
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /PORTALS\.NOPE {2}src\/view\.ts:1$/m);
});
test('skips keys that only appear in comments', () => {
writeSource(
'view.ts',
`// this.translate.instant('PORTALS.OLD')\n/* 'PORTALS.OLDER' | translate */\nconst url = 'https://example.com/a'; // 'PORTALS.TRAILING'\n`
);
writeSource('view.html', `<!-- {{ 'PORTALS.HTML' | translate }} -->\n`);
writeSource(
'inline.component.ts',
"@Component({\n template: `\n <!-- {{ 'PORTALS.INLINE' | translate }} -->\n <p>{{ 'CLOSE' | translate }}</p>\n `,\n})\nexport class InlineComponent {}\n"
);
assert.equal(check().code, 0);
});
test('stripComments keeps string contents and line numbers', () => {
const source = `const a = '// not a comment';\n/* one\ntwo */ const b = 1;`;
const stripped = stripComments(source, 'ts');
assert.equal(stripped.split('\n').length, 3);
assert.match(stripped, /'\/\/ not a comment'/);
assert.doesNotMatch(stripped, /one|two/);
});
test('skips spec files, test harnesses and test stubs', () => {
writeSource('view.spec.ts', `translate.instant('PORTALS.SPEC_ONLY');\n`);
writeSource('view.harness.ts', `translate.instant('PORTALS.HARNESS');\n`);
writeSource('view.spec-data.ts', `const k = 'PORTALS.DATA';\n`);
writeSource('test-stubs/stub.ts', `const k = 'PORTALS.STUB';\n`);
assert.equal(check().code, 0);
});
test('fails clearly when en.json cannot be read', () => {
writeFileSync(path.join(root, 'en.json'), '{');
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /^FAIL .*en\.json: /m);
});