evOo-local/apps/mobile/lib/i18n.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

172 lines
7.0 KiB
TypeScript

import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("expo-localization", () => ({
getLocales: vi.fn(() => [{ languageTag: "en-US" }]),
}));
vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
}));
vi.mock("./ui-direction", () => ({
applyMobileUiDirection: vi.fn(),
}));
/** Recursively collect source files used to validate mobile translation coverage. */
function collectSourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
const path = join(dir, entry.name);
if (entry.isDirectory()) files.push(...collectSourceFiles(path));
else if (/\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith(".test.ts")) files.push(path);
}
return files;
}
describe("mobile i18n", () => {
beforeEach(async () => {
vi.resetModules();
const { getItemAsync, setItemAsync } = await import("expo-secure-store");
vi.mocked(getItemAsync).mockReset();
vi.mocked(setItemAsync).mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("falls back to the English source message when a translation is missing", async () => {
const { resetI18nForTests, t } = await import("./i18n");
resetI18nForTests("zh-CN");
expect(t("Not a real string")).toBe("Not a real string");
});
it("translates seeded Chinese chrome and keeps interpolations", async () => {
const { resetI18nForTests, t } = await import("./i18n");
resetI18nForTests("zh-CN");
expect(t("Account")).toBe("账户");
expect(t("Sign in to Rakazo")).toBe("登录 Rakazo");
expect(t("New bot")).toBe("新建 Bot");
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe(
"3 次运行 · 12 个 token",
);
expect(t("Delete {name}?", { name: "Scout" })).toBe("要删除 Scout 吗?");
resetI18nForTests("ru");
expect(t("Account")).toBe("Аккаунт");
expect(t("Sign in to Rakazo")).toBe("Войти в Rakazo");
expect(t("New bot")).toBe("Новый бот");
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe(
"Запусков: 3 · токенов: 12",
);
expect(t("Delete {name}?", { name: "Scout" })).toBe("Удалить Scout?");
});
it("preserves interpolations in the Chinese and Russian catalogs", async () => {
const { ZH_MESSAGES } = await import("./locales/zh");
const { RU_MESSAGES } = await import("./locales/ru");
for (const messages of [ZH_MESSAGES, RU_MESSAGES]) {
const empty = Object.entries(messages).filter(([, value]) => !value.trim());
const interpolationMismatches = Object.entries(messages).filter(([id, value]) => {
const tokens = (message: string) =>
[...message.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]).sort();
return JSON.stringify(tokens(id)) !== JSON.stringify(tokens(value));
});
expect(empty).toEqual([]);
expect(interpolationMismatches).toEqual([]);
}
});
it("translates every mobile chrome t() id in both non-English catalogs", async () => {
const { ZH_MESSAGES } = await import("./locales/zh");
const { RU_MESSAGES } = await import("./locales/ru");
const { EMPTY_PLUGIN_CATALOG_MESSAGE, SLASH_ACTIONS } = await import("@rakazo/core");
const { OPENAI_COMPATIBLE_BASE_URL_HINT } = await import("@rakazo/contracts");
const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const ids = new Set<string>([
EMPTY_PLUGIN_CATALOG_MESSAGE,
OPENAI_COMPATIBLE_BASE_URL_HINT,
...SLASH_ACTIONS.map((action) => action.label),
"Sign-in did not return a session",
"Sign-up did not return a session",
"{count} model",
"{count} models",
]);
for (const file of collectSourceFiles(mobileRoot)) {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(/\bt\(\s*"((?:\\.|[^"\\])*)"/g)) {
ids.add(JSON.parse(`"${match[1]}"`) as string);
}
}
for (const messages of [ZH_MESSAGES, RU_MESSAGES]) {
const missing = [...ids].filter((id) => !messages[id]?.trim()).sort();
expect(missing).toEqual([]);
}
});
it("prefers the stored locale, then activates and persists a new choice", async () => {
const { getItemAsync, setItemAsync } = await import("expo-secure-store");
vi.mocked(getItemAsync).mockResolvedValue("zh-CN");
const { applyMobileUiDirection } = await import("./ui-direction");
const { bootstrapI18n, getActiveUiLocale, setUiLocale, t } = await import("./i18n");
await expect(bootstrapI18n()).resolves.toBe("zh-CN");
expect(getActiveUiLocale()).toBe("zh-CN");
expect(t("Language")).toBe("语言");
expect(applyMobileUiDirection).toHaveBeenCalledWith("zh-CN");
await setUiLocale("en");
expect(getActiveUiLocale()).toBe("en");
expect(t("Language")).toBe("Language");
expect(setItemAsync).toHaveBeenCalledWith("rakazo.uiLocale", "en");
expect(applyMobileUiDirection).toHaveBeenCalledWith("en");
});
it("normalizes a regional Russian stored locale before activation", async () => {
const { getItemAsync } = await import("expo-secure-store");
vi.mocked(getItemAsync).mockResolvedValue("ru-RU");
const { bootstrapI18n, dateLocaleForUi, getActiveUiLocale, t } = await import("./i18n");
await expect(bootstrapI18n()).resolves.toBe("ru");
expect(getActiveUiLocale()).toBe("ru");
expect(t("Language")).toBe("Язык");
expect(dateLocaleForUi()).toBe("ru");
});
it("keeps the last locale when rapid setUiLocale calls finish out of order", async () => {
const { setItemAsync } = await import("expo-secure-store");
const { applyMobileUiDirection } = await import("./ui-direction");
const { getActiveUiLocale, resetI18nForTests, setUiLocale } = await import("./i18n");
resetI18nForTests("en");
const gates = new Map<string, { release: () => void; wait: Promise<void> }>();
for (const locale of ["en", "zh-CN", "ru"] as const) {
let release!: () => void;
const wait = new Promise<void>((resolve) => {
release = resolve;
});
gates.set(locale, { release, wait });
}
vi.mocked(setItemAsync).mockImplementation(async (_key, value) => {
await gates.get(String(value))?.wait;
});
const first = setUiLocale("en");
const second = setUiLocale("zh-CN");
// First write is slower to settle; second must still win after the queue drains.
queueMicrotask(() => gates.get("zh-CN")?.release());
queueMicrotask(() => gates.get("en")?.release());
await Promise.all([first, second]);
expect(getActiveUiLocale()).toBe("zh-CN");
expect(setItemAsync).toHaveBeenLastCalledWith("rakazo.uiLocale", "zh-CN");
expect(applyMobileUiDirection).toHaveBeenLastCalledWith("zh-CN");
});
});