Browse Source

Post work PR #1025

maziggy 1 month ago
parent
commit
8af2492543

+ 79 - 63
frontend/scripts/check-i18n-parity.mjs

@@ -1,4 +1,3 @@
-#!/usr/bin/env node
 // Verifies parity across locale files (en / zh-CN / zh-TW):
 // Verifies parity across locale files (en / zh-CN / zh-TW):
 //   1. Leaf-key sets are identical
 //   1. Leaf-key sets are identical
 //   2. Each leaf's {{placeholder}} set is identical
 //   2. Each leaf's {{placeholder}} set is identical
@@ -99,76 +98,93 @@ function loadLocale(filePath) {
   return leaves;
   return leaves;
 }
 }
 
 
-const locales = {
-  en: loadLocale(path.join(localesDir, 'en.ts')),
-  'zh-CN': loadLocale(path.join(localesDir, 'zh-CN.ts')),
-  'zh-TW': loadLocale(path.join(localesDir, 'zh-TW.ts')),
-};
-
-let failed = false;
-const MAX_REPORT = 20;
-
-function reportList(label, items) {
-  if (items.length === 0) return;
-  failed = true;
-  console.error(`\n[${label}] (${items.length})`);
-  items.slice(0, MAX_REPORT).forEach((i) => console.error(`  ${i}`));
-  if (items.length > MAX_REPORT) console.error(`  ... and ${items.length - MAX_REPORT} more`);
-}
+const placeholderRe = /\{\{[^{}]+\}\}/g;
 
 
-// Check 1: key set equality
-const enKeys = new Set(locales.en.keys());
-for (const [code, map] of Object.entries(locales)) {
-  if (code === 'en') continue;
-  const keys = new Set(map.keys());
-  const missing = [...enKeys].filter((k) => !keys.has(k)).sort();
-  const extra = [...keys].filter((k) => !enKeys.has(k)).sort();
-  reportList(`${code}: missing keys vs en`, missing);
-  reportList(`${code}: extra keys vs en`, extra);
-}
+// Pure comparison logic, exported so tests can verify each failure mode
+// without going through file IO or the TypeScript parser.
+// Input:  locales = { code: Map<leafKey, leafString> }  (must contain 'en')
+// Output: { failed, reports: Array<{ label, items }> }
+export function compareLocales(locales) {
+  if (!locales.en) throw new Error("compareLocales requires a locales.en entry");
+  const reports = [];
+  const add = (label, items) => {
+    if (items.length) reports.push({ label, items });
+  };
 
 
-// Check 2: placeholder set equality per leaf
-const placeholderRe = /\{\{[^{}]+\}\}/g;
-for (const [code, map] of Object.entries(locales)) {
-  if (code === 'en') continue;
-  const mismatches = [];
-  for (const [key, enValue] of locales.en) {
-    const otherValue = map.get(key);
-    if (otherValue === undefined) continue;
-    const enPlaceholders = new Set((enValue.match(placeholderRe) ?? []));
-    const otherPlaceholders = new Set((otherValue.match(placeholderRe) ?? []));
-    const missingPh = [...enPlaceholders].filter((p) => !otherPlaceholders.has(p));
-    const extraPh = [...otherPlaceholders].filter((p) => !enPlaceholders.has(p));
-    if (missingPh.length || extraPh.length) {
-      mismatches.push(`${key}: en=${[...enPlaceholders].join(',') || '∅'} vs ${code}=${[...otherPlaceholders].join(',') || '∅'}`);
-    }
+  const enKeys = new Set(locales.en.keys());
+
+  // Check 1: key set equality
+  for (const [code, map] of Object.entries(locales)) {
+    if (code === 'en') continue;
+    const keys = new Set(map.keys());
+    const missing = [...enKeys].filter((k) => !keys.has(k)).sort();
+    const extra = [...keys].filter((k) => !enKeys.has(k)).sort();
+    add(`${code}: missing keys vs en`, missing);
+    add(`${code}: extra keys vs en`, extra);
   }
   }
-  reportList(`${code}: placeholder mismatch vs en`, mismatches);
-}
 
 
-// Check 3: plural suffix presence + reverse _one guard
-for (const [code, map] of Object.entries(locales)) {
-  if (code === 'en') continue;
-  const pluralIssues = [];
-  for (const key of enKeys) {
-    if (key.endsWith('_plural') && !map.has(key)) pluralIssues.push(`missing _plural key: ${key}`);
-    if (key.endsWith('_one') && !map.has(key)) pluralIssues.push(`missing _one key: ${key}`);
-    if (key.endsWith('_other') && !map.has(key)) pluralIssues.push(`missing _other key: ${key}`);
+  // Check 2: placeholder set equality per leaf
+  for (const [code, map] of Object.entries(locales)) {
+    if (code === 'en') continue;
+    const mismatches = [];
+    for (const [key, enValue] of locales.en) {
+      const otherValue = map.get(key);
+      if (otherValue === undefined) continue;
+      const enPlaceholders = new Set((enValue.match(placeholderRe) ?? []));
+      const otherPlaceholders = new Set((otherValue.match(placeholderRe) ?? []));
+      const missingPh = [...enPlaceholders].filter((p) => !otherPlaceholders.has(p));
+      const extraPh = [...otherPlaceholders].filter((p) => !enPlaceholders.has(p));
+      if (missingPh.length || extraPh.length) {
+        mismatches.push(`${key}: en=${[...enPlaceholders].join(',') || '∅'} vs ${code}=${[...otherPlaceholders].join(',') || '∅'}`);
+      }
+    }
+    add(`${code}: placeholder mismatch vs en`, mismatches);
   }
   }
-  for (const key of map.keys()) {
-    if (key.endsWith('_one') && !enKeys.has(key)) {
-      pluralIssues.push(`unexpected _one not present in en: ${key}`);
+
+  // Check 3: plural suffix presence + reverse _one guard
+  for (const [code, map] of Object.entries(locales)) {
+    if (code === 'en') continue;
+    const pluralIssues = [];
+    for (const key of enKeys) {
+      if (key.endsWith('_plural') && !map.has(key)) pluralIssues.push(`missing _plural key: ${key}`);
+      if (key.endsWith('_one') && !map.has(key)) pluralIssues.push(`missing _one key: ${key}`);
+      if (key.endsWith('_other') && !map.has(key)) pluralIssues.push(`missing _other key: ${key}`);
+    }
+    for (const key of map.keys()) {
+      if (key.endsWith('_one') && !enKeys.has(key)) {
+        pluralIssues.push(`unexpected _one not present in en: ${key}`);
+      }
     }
     }
+    add(`${code}: plural key mismatch`, pluralIssues);
   }
   }
-  reportList(`${code}: plural key mismatch`, pluralIssues);
-}
 
 
-if (failed) {
-  console.error('\n❌ i18n parity check failed.');
-  process.exit(1);
+  return { failed: reports.length > 0, reports };
 }
 }
 
 
-console.log(`All locales in parity (en / zh-CN / zh-TW):`);
-for (const [code, map] of Object.entries(locales)) {
-  console.log(`  ${code}: ${map.size} leaves`);
+// Skip file IO / process.exit when imported as a library (e.g. from tests).
+const isMainModule = import.meta.url === url.pathToFileURL(process.argv[1] ?? '').href;
+if (isMainModule) {
+  const locales = {
+    en: loadLocale(path.join(localesDir, 'en.ts')),
+    'zh-CN': loadLocale(path.join(localesDir, 'zh-CN.ts')),
+    'zh-TW': loadLocale(path.join(localesDir, 'zh-TW.ts')),
+  };
+
+  const MAX_REPORT = 20;
+  const { failed, reports } = compareLocales(locales);
+  for (const { label, items } of reports) {
+    console.error(`\n[${label}] (${items.length})`);
+    items.slice(0, MAX_REPORT).forEach((i) => console.error(`  ${i}`));
+    if (items.length > MAX_REPORT) console.error(`  ... and ${items.length - MAX_REPORT} more`);
+  }
+
+  if (failed) {
+    console.error('\n❌ i18n parity check failed.');
+    process.exit(1);
+  }
+
+  console.log(`All locales in parity (en / zh-CN / zh-TW):`);
+  for (const [code, map] of Object.entries(locales)) {
+    console.log(`  ${code}: ${map.size} leaves`);
+  }
 }
 }

+ 95 - 0
frontend/src/__tests__/i18n/parity-script.test.ts

@@ -0,0 +1,95 @@
+import { describe, it, expect } from 'vitest';
+// @ts-expect-error -- .mjs script with no type declarations; pure JS import is fine for tests
+import { compareLocales } from '../../../scripts/check-i18n-parity.mjs';
+
+type LocaleMap = Map<string, string>;
+
+const toMap = (obj: Record<string, string>): LocaleMap => new Map(Object.entries(obj));
+
+const hasReport = (
+  reports: Array<{ label: string; items: string[] }>,
+  labelSubstr: string,
+  itemSubstr?: string,
+): boolean =>
+  reports.some(
+    (r) =>
+      r.label.includes(labelSubstr) &&
+      (itemSubstr === undefined || r.items.some((i) => i.includes(itemSubstr))),
+  );
+
+describe('compareLocales (parity-script self-test)', () => {
+  it('passes when all locales match en', () => {
+    const en = toMap({ 'a.b': 'hello {{name}}', 'count_one': 'one', 'count_other': 'many' });
+    const result = compareLocales({
+      en,
+      'zh-CN': toMap({ 'a.b': '你好 {{name}}', 'count_one': '一', 'count_other': '多' }),
+      'zh-TW': toMap({ 'a.b': '你好 {{name}}', 'count_one': '一', 'count_other': '多' }),
+    });
+    expect(result.failed).toBe(false);
+    expect(result.reports).toEqual([]);
+  });
+
+  it('flags keys missing from a non-en locale', () => {
+    const result = compareLocales({
+      en: toMap({ 'a.b': 'x', 'a.c': 'y' }),
+      'zh-TW': toMap({ 'a.b': 'x' }),
+    });
+    expect(result.failed).toBe(true);
+    expect(hasReport(result.reports, 'zh-TW: missing keys vs en', 'a.c')).toBe(true);
+  });
+
+  it('flags keys that exist in a non-en locale but not in en', () => {
+    const result = compareLocales({
+      en: toMap({ 'a.b': 'x' }),
+      'zh-CN': toMap({ 'a.b': 'x', 'a.stray': 'extra' }),
+    });
+    expect(result.failed).toBe(true);
+    expect(hasReport(result.reports, 'zh-CN: extra keys vs en', 'a.stray')).toBe(true);
+  });
+
+  it('flags placeholder mismatch (missing placeholder in translation)', () => {
+    const result = compareLocales({
+      en: toMap({ greeting: 'Hello {{name}}!' }),
+      'zh-CN': toMap({ greeting: '你好!' }), // {{name}} dropped
+    });
+    expect(result.failed).toBe(true);
+    expect(hasReport(result.reports, 'placeholder mismatch', 'greeting')).toBe(true);
+  });
+
+  it('flags placeholder mismatch (translation introduces unknown placeholder)', () => {
+    // This is the exact class of bug the zh-CN sync caught:
+    // fileManager.uploadFailed had a stray {{count}} copied from a sibling key.
+    const result = compareLocales({
+      en: toMap({ uploadFailed: 'Upload failed' }),
+      'zh-CN': toMap({ uploadFailed: '{{count}} 个失败' }),
+    });
+    expect(result.failed).toBe(true);
+    expect(hasReport(result.reports, 'placeholder mismatch', 'uploadFailed')).toBe(true);
+  });
+
+  it('flags missing plural suffix keys in a non-en locale', () => {
+    const result = compareLocales({
+      en: toMap({ item_one: 'item', item_other: 'items' }),
+      'zh-TW': toMap({ item_one: '項目' }), // item_other missing
+    });
+    expect(result.failed).toBe(true);
+    expect(hasReport(result.reports, 'plural key mismatch', 'missing _other')).toBe(true);
+  });
+
+  it('flags a non-en _one key that does not exist in en', () => {
+    const result = compareLocales({
+      en: toMap({ item: 'item' }),
+      'zh-CN': toMap({ item: '项', item_one: '一项' }), // en never plural-gated this
+    });
+    expect(result.failed).toBe(true);
+    expect(
+      hasReport(result.reports, 'plural key mismatch', 'unexpected _one not present in en'),
+    ).toBe(true);
+  });
+
+  it('throws when the en locale is absent', () => {
+    expect(() =>
+      compareLocales({ 'zh-CN': toMap({ a: 'x' }) } as Record<string, LocaleMap>),
+    ).toThrow(/locales\.en/);
+  });
+});

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D8a3o-KR.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DvmJH2YP.js"></script>
+    <script type="module" crossorigin src="/assets/index-D8a3o-KR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
     <link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff