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.
This commit is contained in:
discountry
2026-07-29 21:35:19 +08:00
parent b9331c516a
commit 22b9c5a39d
14 changed files with 964 additions and 273 deletions
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { t } from "../src/i18n";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AccountSnapshot, Depth, Kline, Order, Ticker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
@@ -103,7 +104,11 @@ describe("MakerPointsEngine Binance depth health defense", () => {
expect((engine as any).defenseMode).toBe(true);
const logs = ((engine as any).tradeLog.all() as Array<{ detail: string }>).map((entry) => entry.detail);
expect(logs.some((detail) => detail.includes("Binance簿记异常(orderbook_not_ready)"))).toBe(true);
expect(
logs.some((detail) =>
detail.includes(t("defense.reason.binanceBook", { reason: "orderbook_not_ready" }))
)
).toBe(true);
engine.stop();
});
+71
View File
@@ -0,0 +1,71 @@
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}");
});
});