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
+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);
});
});