Files
buzz-sheet/lib/i18n/i18n.test.tsx
T
gunshiz e0794822b1
CI / Verify (push) Successful in 1m18s
CI / Build immutable images and deploy (push) Successful in 1m57s
feat(i18n) : add personal languages and compact weapon exports
2026-09-14 16:44:44 +07:00

107 lines
6.0 KiB
TypeScript

import Link from "next/link";
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { LocaleBoundary } from "@/components/i18n/locale-provider";
import { LanguageDropdown } from "@/components/i18n/language-dropdown";
import { Text, Localized, ExportText, ConditionLabel, SectionTitle } from "@/components/i18n/text";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { createLocaleStore } from "./locale-store";
import { isLocale, languages, localeStorageKey } from "./locales";
import { messages, translate } from "./messages";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
function memoryStorage(initial?: string) {
const values = new Map<string, string>();
if (initial) values.set(localeStorageKey, initial);
return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); } };
}
describe("personal language preference", () => {
it("renders Thai on the server and restores a saved browser choice on subscription", () => {
const storage = memoryStorage("en");
const store = createLocaleStore(() => storage);
expect(store.getServerSnapshot()).toBe("th");
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
expect(store.getSnapshot()).toBe("en");
store.setLocale("th");
expect(storage.getItem(localeStorageKey)).toBe("th");
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
store.setLocale("en");
expect(listener).toHaveBeenCalledTimes(2);
});
it("keeps choices independent between users' browsers", () => {
const first = createLocaleStore(() => memoryStorage());
const second = createLocaleStore(() => memoryStorage());
first.subscribe(() => {});
second.subscribe(() => {});
first.setLocale("en");
expect(first.getSnapshot()).toBe("en");
expect(second.getSnapshot()).toBe("th");
});
it("falls back to Thai for invalid storage and still changes language when storage is blocked", () => {
const invalid = createLocaleStore(() => memoryStorage("invalid"));
invalid.subscribe(() => {});
expect(invalid.getSnapshot()).toBe("th");
const blocked = createLocaleStore(() => { throw new Error("Storage disabled"); });
blocked.subscribe(() => {});
blocked.setLocale("en");
expect(blocked.getSnapshot()).toBe("en");
expect(isLocale("invalid")).toBe(false);
});
});
describe("interface translations", () => {
it("has matching named placeholders and English translations without Thai interface text", () => {
for (const [key, value] of Object.entries(messages)) {
const placeholders = (text: string) => [...text.matchAll(/\{(\w+)\}/gu)].map((match) => match[1]).sort();
expect(placeholders(value.en), key).toEqual(placeholders(value.th));
expect(value.en, key).not.toMatch(/[\u0e00-\u0e7f]/u);
}
});
it("translates app-owned API messages with interpolated values without changing their response shape", () => {
expect(translate("สร้างบัญชี [email protected] แล้ว", {}, "en")).toBe("Account created for [email protected]");
expect(translate("ดึงข้อมูลตัวละคร 4/10", {}, "en")).toBe("Fetching characters 4/10");
expect(translate("{character} ไม่รองรับอาวุธ {weapon}", { character: "Raiden", weapon: "Bow" }, "en")).toBe("Raiden cannot use Bow.");
});
it("localises validation while preserving an authored Thai team name", () => {
expect(formatGuideValidationIssues("team", [{ path: ["teams", 0, "members", 1, "damage"], message: "Invalid input" }], { locale: "en", teamNames: ["ทีมของฉัน"] })).toEqual(["ทีมของฉัน · Character 2: Enter damage of zero or more."]);
});
it("updates text and accessible attributes through the same locale context", () => {
const html = renderToStaticMarkup(<LocaleBoundary locale="en"><Text message="ค้นหาไกด์" /><Localized attributes={{ "aria-label": "กลับหน้ารวม" }}><Link href="/">Home</Link></Localized><ExportText message="talents" /></LocaleBoundary>);
expect(html).toContain("Search guides");
expect(html).toContain('aria-label="Back to all guides"');
expect(html).toContain("Talent Priority");
});
it("preserves custom content while translating only generated headings and conditions", () => {
const html = renderToStaticMarkup(<LocaleBoundary locale="en"><input defaultValue="ชื่อภาษาไทย" /><ConditionLabel name="เงื่อนไขของฉัน" index={1} /><ConditionLabel name={null} index={2} /><SectionTitle slug="custom" fallback="หัวข้อของฉัน" /></LocaleBoundary>);
expect(html).toContain('value="ชื่อภาษาไทย"');
expect(html).toContain("เงื่อนไขของฉัน");
expect(html).toContain("Condition 2");
expect(html).toContain("หัวข้อของฉัน");
});
it.each(["th", "en"] as const)("shows a labelled dropdown with the selected SVG flag in %s", (locale) => {
const language = languages.find((item) => item.code === locale)!;
const html = renderToStaticMarkup(<LocaleBoundary locale={locale}><LanguageDropdown /></LocaleBoundary>);
expect(html).toContain(language.name);
expect(html).toContain(language.flag);
expect(html).toContain('aria-haspopup="menu"');
for (const item of languages) expect(readFileSync(`public${item.flag}`, "utf8")).toContain("<svg");
});
it.each(["th", "en"] as const)("offers both export modes in %s even with no notes", (locale) => {
const html = renderToStaticMarkup(<LocaleBoundary locale={locale}><GuideImageExport fileName="test.png">No notes</GuideImageExport></LocaleBoundary>);
expect(html).toContain(locale === "en" ? "Export without note" : "ส่งออกภาพโดยไม่รวมโน้ต");
expect(html.match(/<button\b/gu)).toHaveLength(2);
});
});