feat(webui): add i18n support and locale switcher
This commit is contained in:
@@ -144,5 +144,5 @@ describe("App layout", () => {
|
||||
);
|
||||
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
|
||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
import { fmtDateTime, relativeTime } from "@/lib/format";
|
||||
|
||||
describe("localized format helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-18T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("formats relative time using the active locale", async () => {
|
||||
const value = "2026-04-18T11:59:00Z";
|
||||
|
||||
await setAppLanguage("en");
|
||||
const english = relativeTime(value);
|
||||
|
||||
await setAppLanguage("zh-CN");
|
||||
const chinese = relativeTime(value);
|
||||
|
||||
expect(english).toBe(
|
||||
new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
|
||||
-1,
|
||||
"minute",
|
||||
),
|
||||
);
|
||||
expect(chinese).toBe(
|
||||
new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }).format(
|
||||
-1,
|
||||
"minute",
|
||||
),
|
||||
);
|
||||
expect(english).not.toBe(chinese);
|
||||
});
|
||||
|
||||
it("formats date-time using the active locale", async () => {
|
||||
const value = "2026-04-18T08:30:00Z";
|
||||
const date = new Date(value);
|
||||
|
||||
await setAppLanguage("en");
|
||||
const english = fmtDateTime(value);
|
||||
|
||||
await setAppLanguage("fr");
|
||||
const french = fmtDateTime(value);
|
||||
|
||||
expect(english).toBe(
|
||||
new Intl.DateTimeFormat("en", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date),
|
||||
);
|
||||
expect(french).toBe(
|
||||
new Intl.DateTimeFormat("fr", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date),
|
||||
);
|
||||
expect(english).not.toBe(french);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
|
||||
describe("webui i18n", () => {
|
||||
it("switches UI copy and document locale through the language switcher", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
<ThreadComposer onSend={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText("Type your message…"),
|
||||
).toBeInTheDocument();
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Change language" }));
|
||||
await user.click(screen.getByRole("menuitemradio", { name: /简体中文/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.lang).toBe("zh-CN");
|
||||
});
|
||||
expect(localStorage.getItem("nanobot.locale")).toBe("zh-CN");
|
||||
expect(screen.getByPlaceholderText("输入消息…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the composer aria label when the language changes", async () => {
|
||||
render(<ThreadComposer onSend={vi.fn()} />);
|
||||
|
||||
await act(async () => {
|
||||
const { setAppLanguage } = await import("@/i18n");
|
||||
await setAppLanguage("ja");
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { beforeEach } from "vitest";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
|
||||
// happy-dom doesn't ship with ``crypto.randomUUID``; shim a tiny v4-ish helper.
|
||||
if (!("randomUUID" in globalThis.crypto)) {
|
||||
@@ -12,3 +15,10 @@ if (!("randomUUID" in globalThis.crypto)) {
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
document.documentElement.lang = "en";
|
||||
document.title = "nanobot";
|
||||
localStorage.setItem("nanobot.locale", "en");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user