123 lines
6.6 KiB
TypeScript
123 lines
6.6 KiB
TypeScript
import Link from "next/link";
|
|
import type { ReactElement } from "react";
|
|
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("localises a lazy child streamed from a server component", () => {
|
|
const streamedChild = {
|
|
$$typeof: Symbol.for("react.lazy"),
|
|
_payload: <Link href="/">Home</Link>,
|
|
_init: (element: ReactElement) => element,
|
|
} as unknown as ReactElement;
|
|
const html = renderToStaticMarkup(
|
|
<LocaleBoundary locale="en">
|
|
<Localized attributes={{ "aria-label": "กลับหน้ารวม" }}>{streamedChild}</Localized>
|
|
</LocaleBoundary>,
|
|
);
|
|
expect(html).toContain('aria-label="Back to all guides"');
|
|
expect(html).toContain("Home");
|
|
});
|
|
|
|
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 allowWithoutNote fileName="test.png">No notes</GuideImageExport></LocaleBoundary>);
|
|
expect(html).toContain(locale === "en" ? "Export without note" : "ส่งออกภาพโดยไม่รวมโน้ต");
|
|
expect(html.match(/<button\b/gu)).toHaveLength(2);
|
|
});
|
|
});
|