feat: add internationalization message platform

This commit is contained in:
donghyeon-ka
2026-07-26 16:17:39 +09:00
parent b8c0444217
commit 668bf05b48
57 changed files with 1711 additions and 201 deletions
+3 -1
View File
@@ -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();
+120
View File
@@ -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();
});
});
+1 -1
View File
@@ -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");
});
});
+4 -2
View File
@@ -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 />
+41
View File
@@ -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();
+5
View File
@@ -0,0 +1,5 @@
export function UnsafeBackendFailure({
failure,
}: Readonly<{ failure: { message: string } }>) {
return <p>{failure.message}</p>;
}
+3
View File
@@ -0,0 +1,3 @@
export function HardcodedCommonAction() {
return <button type="button"> </button>;
}
+3
View File
@@ -0,0 +1,3 @@
export function UnsafeTranslatedMarkup({ value }: Readonly<{ value: string }>) {
return <p dangerouslySetInnerHTML={{ __html: value }} />;
}
+3
View File
@@ -0,0 +1,3 @@
import { formatMessage } from "../../../src/presentation/i18n/index.js";
formatMessage("ko-KR", "unknown.translation.key");
+5
View File
@@ -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" });
+115
View File
@@ -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);
});
});