mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
* 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>
233 lines
7.1 KiB
JavaScript
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);
|
|
});
|