feat: add internationalization message platform
This commit is contained in:
@@ -50,7 +50,9 @@ describe("async UI state matrix", () => {
|
||||
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
|
||||
|
||||
expect(screen.getByText("existing content")).toBeVisible();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"최신 정보를 확인하고 있습니다.",
|
||||
);
|
||||
});
|
||||
|
||||
it("connects stale retry and conflict resolution to real callbacks", async () => {
|
||||
|
||||
@@ -60,7 +60,9 @@ describe("chunk recovery boundary classification", () => {
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"화면을 표시하지 못했습니다.",
|
||||
);
|
||||
expect(recover).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,7 +189,7 @@ describe("design-system platform interactions", () => {
|
||||
await user.click(screen.getByRole("button", { name: "중복" }));
|
||||
expect(screen.getByText("×2")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "다섯 개" }));
|
||||
const region = screen.getByRole("region", { name: "Notifications" });
|
||||
const region = screen.getByRole("region", { name: "알림" });
|
||||
expect(within(region).getAllByRole("article")).toHaveLength(3);
|
||||
expect(within(region).queryByText("알림 0")).not.toBeInTheDocument();
|
||||
expect(within(region).getByText("알림 4")).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Pagination,
|
||||
Tabs,
|
||||
} from "../../src/presentation/design-system/index.js";
|
||||
import {
|
||||
LocaleProvider,
|
||||
useLocale,
|
||||
type SupportedLocale,
|
||||
} from "../../src/presentation/i18n/index.js";
|
||||
|
||||
function LocaleHarness() {
|
||||
const { direction, locale, message, setLocale } = useLocale();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="test-locale">{message("shell.locale")}</label>
|
||||
<select
|
||||
id="test-locale"
|
||||
onChange={(event) =>
|
||||
setLocale(event.currentTarget.value as SupportedLocale)
|
||||
}
|
||||
value={locale}
|
||||
>
|
||||
<option value="ko-KR">한국어</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="ar-EG">RTL</option>
|
||||
</select>
|
||||
<output>{`${locale}:${direction}`}</output>
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
{message("shell.menu")}
|
||||
</Button>
|
||||
<Drawer
|
||||
closeLabel={message("shell.closeMenu")}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
open={drawerOpen}
|
||||
title={message("shell.menu")}
|
||||
>
|
||||
content
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("locale provider runtime", () => {
|
||||
it("switches copy and synchronizes the document language and direction", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LocaleProvider>
|
||||
<LocaleHarness />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
|
||||
expect(document.documentElement).toHaveAttribute("lang", "ko-KR");
|
||||
await user.selectOptions(screen.getByLabelText("언어"), "en-US");
|
||||
expect(screen.getByLabelText("Language")).toHaveValue("en-US");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "en-US");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Language"), "ar-EG");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "ar-EG");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
||||
await user.click(screen.getByRole("button", { name: "Menu" }));
|
||||
expect(screen.getByRole("dialog", { name: "Menu" })).toHaveAttribute(
|
||||
"open",
|
||||
);
|
||||
});
|
||||
|
||||
it("reverses horizontal tab focus semantics in RTL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LocaleProvider initialLocale="ar-EG">
|
||||
<Tabs
|
||||
activation="manual"
|
||||
label="sections"
|
||||
tabs={[
|
||||
{ id: "one", label: "One", panel: "One panel" },
|
||||
{ id: "two", label: "Two", panel: "Two panel" },
|
||||
{ id: "three", label: "Three", panel: "Three panel" },
|
||||
]}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
const first = screen.getByRole("tab", { name: "One" });
|
||||
first.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Three" })).toHaveFocus();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(first).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps pagination semantics while direction-aware icons remain decorative", () => {
|
||||
render(
|
||||
<LocaleProvider initialLocale="ar-EG">
|
||||
<Pagination
|
||||
label="pages"
|
||||
nextLabel="Next page"
|
||||
onChange={() => {}}
|
||||
page={2}
|
||||
pageCount={3}
|
||||
pageLabel={(page) => `Page ${page}`}
|
||||
previousLabel="Previous page"
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,7 @@ describe("page template slot contracts", () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Offline" })).toBeVisible();
|
||||
expect(screen.getByText("SAFE-123")).toBeVisible();
|
||||
expect(screen.getByText(/SAFE-123/)).toBeVisible();
|
||||
expect(document.body).not.toHaveTextContent("stack");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,9 @@ describe("render recovery boundaries", () => {
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"화면을 표시하지 못했습니다.",
|
||||
);
|
||||
expect(onRenderFailure).toHaveBeenCalledWith({
|
||||
routeId: "APP_HOME",
|
||||
buildId: "build-a",
|
||||
@@ -83,7 +85,7 @@ describe("render recovery boundaries", () => {
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
shouldThrow = false;
|
||||
await user.click(screen.getByRole("button", { name: "retry" }));
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
view.rerender(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<Recoverable />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("switches the shell locale and keeps pseudo-locale copy within compact layout", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
await page.goto("/");
|
||||
|
||||
const locale = page.getByRole("combobox", { name: "언어" });
|
||||
await locale.selectOption("en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "ltr");
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
|
||||
await page.getByRole("combobox", { name: "Language" }).selectOption("en-XA");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-XA");
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
|
||||
await expect(page.getByRole("button", { name: /Ménú/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("applies RTL to the shell and modal navigation without changing action semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("combobox", { name: "언어" }).selectOption("ar-EG");
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "ar-EG");
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
|
||||
const trigger = page.getByRole("button", { name: "Menu" });
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog", { name: "Menu" })).toBeVisible();
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" }))
|
||||
.toHaveCount(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
@@ -72,10 +72,10 @@ describe("reference feature boundary contracts", () => {
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
});
|
||||
if (!("id" in model)) throw new Error("expected one model");
|
||||
expect(toReferenceView(model, () => "formatted")).toEqual({
|
||||
expect(toReferenceView(model)).toEqual({
|
||||
resourceId: "reference-1",
|
||||
title: "Example",
|
||||
createdAtLabel: "formatted",
|
||||
createdAt: "2026-07-26T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ function inputWith(
|
||||
value: {
|
||||
resourceId: "created",
|
||||
title: name,
|
||||
createdAtLabel: null,
|
||||
createdAt: null,
|
||||
},
|
||||
}),
|
||||
getResource: async (resourceId) => ({
|
||||
@@ -63,7 +63,7 @@ function inputWith(
|
||||
value: {
|
||||
resourceId,
|
||||
title: "Detail",
|
||||
createdAtLabel: null,
|
||||
createdAt: null,
|
||||
},
|
||||
}),
|
||||
...overrides,
|
||||
@@ -90,7 +90,7 @@ describe("reference feature page states", () => {
|
||||
{
|
||||
resourceId: "reference-1",
|
||||
title: "Loaded",
|
||||
createdAtLabel: null,
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -191,7 +191,7 @@ describe("reference feature page states", () => {
|
||||
{
|
||||
resourceId: "existing",
|
||||
title: "Existing",
|
||||
createdAtLabel: null,
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -209,7 +209,7 @@ describe("reference feature page states", () => {
|
||||
{
|
||||
resourceId: "recovered",
|
||||
title: "Recovered",
|
||||
createdAtLabel: null,
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -217,7 +217,9 @@ describe("reference feature page states", () => {
|
||||
await screen.findByText("Existing");
|
||||
await user.click(screen.getByRole("button", { name: "새로고침" }));
|
||||
|
||||
expect(await screen.findByText("stale-degraded")).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText("기존 정보를 표시하고 있습니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("Existing")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(await screen.findByText("Recovered")).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function UnsafeBackendFailure({
|
||||
failure,
|
||||
}: Readonly<{ failure: { message: string } }>) {
|
||||
return <p>{failure.message}</p>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function HardcodedCommonAction() {
|
||||
return <button type="button">다시 시도</button>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function UnsafeTranslatedMarkup({ value }: Readonly<{ value: string }>) {
|
||||
return <p dangerouslySetInnerHTML={{ __html: value }} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { formatMessage } from "../../../src/presentation/i18n/index.js";
|
||||
|
||||
formatMessage("ko-KR", "unknown.translation.key");
|
||||
@@ -0,0 +1,5 @@
|
||||
import { formatMessage } from "../../../src/presentation/i18n/index.js";
|
||||
|
||||
formatMessage("en-US", "route.documentTitle");
|
||||
formatMessage("en-US", "route.documentTitle", { title: "Missing app name" });
|
||||
formatMessage("en-US", "action.retry", { unexpected: "value" });
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
catalogKeys,
|
||||
FORMAT_FALLBACK,
|
||||
formatDate,
|
||||
formatList,
|
||||
formatMessage,
|
||||
formatNumber,
|
||||
formatRelativeTime,
|
||||
localeDirection,
|
||||
messagePlaceholders,
|
||||
normalizeLocale,
|
||||
resolveMessage,
|
||||
selectMessage,
|
||||
selectPlural,
|
||||
} from "../../src/presentation/i18n/index.js";
|
||||
import {
|
||||
EN_MESSAGES,
|
||||
KO_MESSAGES,
|
||||
MESSAGE_CATALOGS,
|
||||
} from "../../src/presentation/i18n/catalog.js";
|
||||
|
||||
describe("internationalization message contract", () => {
|
||||
it("keeps every registered catalog and interpolation contract in parity", () => {
|
||||
expect(catalogKeys("en-US")).toEqual(catalogKeys("ko-KR"));
|
||||
expect(catalogKeys("ar-EG")).toEqual(catalogKeys("ko-KR"));
|
||||
|
||||
for (const key of catalogKeys("ko-KR")) {
|
||||
expect(messagePlaceholders(EN_MESSAGES[key])).toEqual(
|
||||
messagePlaceholders(KO_MESSAGES[key]),
|
||||
);
|
||||
}
|
||||
expect(MESSAGE_CATALOGS["ar-EG"]).toBe(EN_MESSAGES);
|
||||
});
|
||||
|
||||
it("formats typed parameters and expands the pseudo locale", () => {
|
||||
expect(
|
||||
formatMessage("en-US", "route.documentTitle", {
|
||||
title: "Settings",
|
||||
appName: "Skeleton",
|
||||
}),
|
||||
).toBe("Settings · Skeleton");
|
||||
const pseudo = formatMessage("en-XA", "action.closeNamed", {
|
||||
title: "Account",
|
||||
});
|
||||
expect(pseudo).toMatch(/^[.+ ···]$/);
|
||||
expect(pseudo.length).toBeGreaterThan("Close Account".length);
|
||||
});
|
||||
|
||||
it("falls back safely for unknown locale, key and missing interpolation", () => {
|
||||
expect(normalizeLocale("fr-FR")).toBe("ko-KR");
|
||||
expect(resolveMessage("fr-FR", "action.retry")).toBe("다시 시도");
|
||||
expect(resolveMessage("en-US", "action.login")).toBe("Sign in");
|
||||
expect(resolveMessage("en-US", "backend.stack.trace")).toBe(
|
||||
"요청한 문구를 표시할 수 없습니다.",
|
||||
);
|
||||
expect(resolveMessage("en-US", "action.closeNamed")).toBe(
|
||||
"요청한 문구를 표시할 수 없습니다.",
|
||||
);
|
||||
expect(resolveMessage("en-US", "backend.stack.trace")).not.toContain(
|
||||
"backend.stack.trace",
|
||||
);
|
||||
});
|
||||
|
||||
it("defines direction without inferring it in components", () => {
|
||||
expect(localeDirection("ko-KR")).toBe("ltr");
|
||||
expect(localeDirection("en-XA")).toBe("ltr");
|
||||
expect(localeDirection("ar-EG")).toBe("rtl");
|
||||
});
|
||||
});
|
||||
|
||||
describe("internationalization formatter contract", () => {
|
||||
const instant = new Date("2026-07-26T23:30:00.000Z");
|
||||
|
||||
it("uses an explicit UTC default and deterministic locale formatting", () => {
|
||||
expect(formatDate("en-US", instant, { dateStyle: "short" })).toBe(
|
||||
"7/26/26",
|
||||
);
|
||||
expect(
|
||||
formatDate("en-US", instant, {
|
||||
dateStyle: "short",
|
||||
timeZone: "Asia/Seoul",
|
||||
}),
|
||||
).toBe("7/27/26");
|
||||
expect(formatNumber("en-US", 1234.5)).toBe("1,234.5");
|
||||
expect(formatRelativeTime("en-US", -1, "day")).toBe("yesterday");
|
||||
expect(formatList("en-US", ["one", "two", "three"])).toBe(
|
||||
"one, two, and three",
|
||||
);
|
||||
expect(selectPlural("en-US", 1, { one: "item", other: "items" })).toBe(
|
||||
"item",
|
||||
);
|
||||
expect(
|
||||
selectPlural("en-US", 3, { one: "item", other: "items" }),
|
||||
).toBe("items");
|
||||
expect(
|
||||
selectMessage("pending", {
|
||||
ready: "Ready",
|
||||
pending: "Pending",
|
||||
other: "Unknown",
|
||||
}),
|
||||
).toBe("Pending");
|
||||
});
|
||||
|
||||
it("does not throw or expose invalid formatter input", () => {
|
||||
expect(formatDate("en-US", Number.NaN)).toBe(FORMAT_FALLBACK);
|
||||
expect(formatNumber("en-US", Number.POSITIVE_INFINITY)).toBe(
|
||||
FORMAT_FALLBACK,
|
||||
);
|
||||
expect(
|
||||
formatDate("en-US", instant, { timeZone: "not/a-time-zone" }),
|
||||
).toBe(FORMAT_FALLBACK);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user