Files
ritmex-bot/tests/i18n-coverage.test.ts
discountry 22b9c5a39d i18n: route the remaining 300 hardcoded strings through the table
Grid, offset-maker, liquidity-maker and maker-points wrote their trade log and
Telegram alerts as Chinese literals, so LANG=en changed the menu but not a
single runtime message. src/ now holds zero user-facing Chinese literals.

Reuses rather than duplicates: the four engines' subscription boilerplate maps
onto the existing log.subscribe.* / log.process.* keys, and the spot-maker
wording shared by offset-maker and liquidity-maker became one log.spotMaker.*
set instead of two.

Exchange gateways were treated differently on purpose: aster's exception and
console.error text is developer diagnostics, not UI, and its eight sibling
gateways already used English — so those were translated to English rather than
added to the table.

Adds tests/i18n-coverage.test.ts to hold the line: it fails on any new CJK
literal in src/, on a key missing either language, and on a duplicate key.
Several existing tests asserted the Chinese literals, which is what let the gap
persist; they now assert the resolved key and hold in either language.

214 new keys (300 -> 514). 275 pass; tsc and oxlint clean.
2026-07-29 21:35:19 +08:00

72 lines
2.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { t } from "../src/i18n";
const SRC = join(import.meta.dirname, "..", "src");
const I18N_FILE = join(SRC, "i18n", "index.ts");
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full, out);
} else if (/\.tsx?$/.test(entry)) {
out.push(full);
}
}
return out;
}
/** A string or template literal containing a CJK character. */
const CJK_IN_LITERAL = /["`][^"`\n]*[一-龥][^"`\n]*["`]/;
describe("i18n coverage", () => {
it("keeps user-facing text out of source files", () => {
// Chinese literals outside the translation table cannot be shown in English,
// which is how the order log, grid events and defense alerts stayed
// untranslatable for so long.
const offenders: string[] = [];
for (const file of walk(SRC)) {
if (file === I18N_FILE) continue;
if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue;
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, index) => {
if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*")) return;
if (CJK_IN_LITERAL.test(line)) {
offenders.push(`${file.slice(SRC.length + 1)}:${index + 1} ${line.trim()}`);
}
});
}
expect(offenders).toEqual([]);
});
it("gives every key both a zh and an en translation", () => {
const source = readFileSync(I18N_FILE, "utf8");
const table = source.slice(
source.indexOf("const translations"),
source.indexOf("const formatTemplate")
);
const keys = [...table.matchAll(/^ {2}"([\w.]+)":/gm)].map((m) => m[1]!);
expect(keys.length).toBeGreaterThan(400);
const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index);
expect(duplicates).toEqual([]);
for (const key of keys) {
expect(t(key, {}, "zh"), `${key} missing zh`).not.toBe(key);
expect(t(key, {}, "en"), `${key} missing en`).not.toBe(key);
}
});
it("substitutes placeholders in both languages", () => {
expect(t("log.order.closePlaced", { side: "BUY" }, "zh")).toContain("BUY");
expect(t("log.order.closePlaced", { side: "BUY" }, "en")).toContain("BUY");
expect(t("log.order.closePlaced", { side: "BUY" }, "en")).not.toContain("{side}");
});
it("leaves an unknown placeholder visible rather than printing undefined", () => {
expect(t("log.order.closePlaced", {}, "en")).toContain("{side}");
});
});