diff --git a/src/components/layout/language-selector.tsx b/src/components/layout/language-selector.tsx new file mode 100644 index 00000000..258fc6f3 --- /dev/null +++ b/src/components/layout/language-selector.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import type { ChangeEvent } from "react"; +import { useId } from "react"; +import type { UiMessages } from "@/lib/content/ui-messages.types"; +import { + buildLocalizedRoute, + defaultLocale, + localeOptions, + resolveLocalizedRouteSwitch, + type SiteLocale, +} from "@/lib/i18n/locale-routing"; + +type LanguageSelectorProps = { + messages: UiMessages; + locale?: SiteLocale; +}; + +export function LanguageSelector({ + messages, + locale = defaultLocale, +}: LanguageSelectorProps) { + const pathname = usePathname(); + const selectId = useId(); + const unavailableHintId = useId(); + const routePath = + pathname || buildLocalizedRoute({ surface: "home" }, locale); + const options = localeOptions.map((option) => { + if (option.code === locale) { + return { ...option, available: true }; + } + + return { + ...option, + ...resolveLocalizedRouteSwitch(routePath, option.code), + }; + }); + const unavailableOptions = options.filter((option) => !option.available); + const unavailableSummary = + unavailableOptions.length > 0 + ? `${messages.nav.unavailableOnPage}: ${unavailableOptions + .map((option) => option.label) + .join(", ")}` + : null; + + function onChange(event: ChangeEvent) { + const nextLocale = event.currentTarget.value as SiteLocale; + + if (nextLocale === locale || typeof window === "undefined") { + return; + } + + const activePath = `${routePath}${window.location.search}${window.location.hash}`; + const fallbackPath = routePath; + const resolved = resolveLocalizedRouteSwitch( + activePath || fallbackPath, + nextLocale, + ); + + if (!resolved.available) { + return; + } + + window.location.assign(resolved.href); + } + + return ( +
+ + + {unavailableSummary ? ( + + {unavailableSummary} + + ) : null} +
+ ); +} diff --git a/src/components/layout/model-atlas-docs-header.test.tsx b/src/components/layout/model-atlas-docs-header.test.tsx index 23fb228d..dce28f18 100644 --- a/src/components/layout/model-atlas-docs-header.test.tsx +++ b/src/components/layout/model-atlas-docs-header.test.tsx @@ -14,14 +14,43 @@ import { PRIMARY_NAV_MOBILE_PANEL_CLASS, } from "@/components/layout/primary-nav"; import { loadUiMessages } from "@/lib/content/ui-messages"; +import { localeOptions } from "@/lib/i18n/locale-routing"; import { assertPrimaryNavNoDuplicateSearchLink } from "@/lib/verify/customer-ask-home-header-convergence"; +import { + resetMockNavigation, + setMockPathname, +} from "@/tests/a11y/mock-navigation"; import { renderWithAppProviders } from "@/tests/a11y/render"; describe("ModelAtlasDocsHeader", () => { afterEach(() => { cleanup(); + resetMockNavigation(); }); + function withWindowLocation( + location: Pick & + Partial>, + run: () => Promise, + ): Promise { + const originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...originalLocation, + ...location, + assign: location.assign ?? originalLocation.assign, + }, + }); + + return run().finally(() => { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + }); + } + test("renders header search trigger without duplicate /search primary nav link", async () => { const messages = await loadUiMessages(); const SearchDialog: ComponentType = () => null; @@ -52,6 +81,131 @@ describe("ModelAtlasDocsHeader", () => { expect(html).toContain('data-search=""'); expect(html).toContain(`aria-label="${messages.search.open}"`); expect(html).toContain(messages.search.shortcut); + expect(html).toContain(`aria-label="${messages.nav.language}"`); + for (const option of localeOptions) { + expect(html).toContain(`value="${option.code}"`); + expect(html).toContain(option.label); + } + }); + + test("renders the shared locale selector with the active locale selected", async () => { + const messages = await loadUiMessages("vi"); + const SearchDialog: ComponentType = () => null; + await renderWithAppProviders( + , + { + SearchDialog, + }, + ); + + const selector = screen.getByRole("combobox", { + name: messages.nav.language, + }) as HTMLSelectElement; + const selectorLabel = selector.closest("div")?.querySelector("label"); + expect(selector.value).toBe("vi"); + expect(selector.getAttribute("id")).toBeTruthy(); + expect(selectorLabel?.tagName).toBe("LABEL"); + expect(selectorLabel?.getAttribute("for")).toBe(selector.id); + + const options = within(selector).getAllByRole("option"); + expect(options).toHaveLength(localeOptions.length); + expect(options.map((option) => option.textContent)).toEqual([ + "English", + "Tiếng Việt (Hiện tại)", + "日本語", + ]); + expect(options.map((option) => option.getAttribute("lang"))).toEqual([ + "en", + "vi", + "ja", + ]); + }); + + test("marks unshipped locale destinations as unavailable and does not navigate to them", async () => { + const messages = await loadUiMessages(); + const SearchDialog: ComponentType = () => null; + const assignedUrls: string[] = []; + + await withWindowLocation( + { + assign: (url: string | URL) => { + assignedUrls.push(String(url)); + }, + pathname: "/docs/modules/grouped-query-attention", + search: "", + hash: "", + }, + async () => { + setMockPathname("/docs/modules/grouped-query-attention"); + await renderWithAppProviders( + , + { + SearchDialog, + }, + ); + + const selector = screen.getByRole("combobox", { + name: messages.nav.language, + }) as HTMLSelectElement; + const options = within(selector).getAllByRole( + "option", + ) as HTMLOptionElement[]; + const japaneseOption = options.find((option) => option.value === "ja"); + + expect(japaneseOption?.textContent).toBe("日本語 (Unavailable)"); + expect(japaneseOption?.hasAttribute("disabled")).toBe(true); + const unavailableStatus = screen.getByText( + "Unavailable on this page: 日本語", + ); + expect(unavailableStatus.getAttribute("role")).toBe("status"); + expect(selector.getAttribute("aria-describedby")).toBe( + unavailableStatus.id, + ); + + fireEvent.change(selector, { + target: { value: "ja" }, + }); + + expect(assignedUrls).toEqual([]); + }, + ); + }); + + test("switches to the localized equivalent route while preserving query and hash state", async () => { + const messages = await loadUiMessages(); + const SearchDialog: ComponentType = () => null; + const assignedUrls: string[] = []; + + await withWindowLocation( + { + assign: (url: string | URL) => { + assignedUrls.push(String(url)); + }, + pathname: "/docs/modules/grouped-query-attention", + search: "?view=graph", + hash: "#kv-cache", + }, + async () => { + setMockPathname("/docs/modules/grouped-query-attention"); + await renderWithAppProviders( + , + { + SearchDialog, + }, + ); + + fireEvent.change( + screen.getByRole("combobox", { name: messages.nav.language }), + { + target: { value: "vi" }, + }, + ); + + expect(assignedUrls).toEqual([ + "/vi/docs/modules/grouped-query-attention?view=graph#kv-cache", + ]); + }, + ); }); test("mobile width markup hides desktop inline nav links and exposes the menu control", async () => { @@ -135,6 +289,12 @@ describe("ModelAtlasDocsHeader", () => { expect(panel).toBeTruthy(); expect(panel?.className).toContain(PRIMARY_NAV_MOBILE_PANEL_CLASS); + const selector = screen.getByRole("combobox", { + name: messages.nav.language, + }); + selector.focus(); + expect(document.activeElement).toBe(selector); + const expectedItems = getPrimaryNavItems(messages); for (const item of expectedItems) { const link = within(panel as HTMLElement).getByRole("link", { @@ -189,6 +349,9 @@ describe("ModelAtlasDocsHeader", () => { }); const user = userEvent.setup(); const menuButton = screen.getByRole("button", { name: messages.nav.menu }); + const languageSelector = screen.getByRole("combobox", { + name: messages.nav.language, + }); const searchTrigger = screen.getByRole("button", { name: messages.search.open, }); @@ -213,6 +376,9 @@ describe("ModelAtlasDocsHeader", () => { expect(document.activeElement).toBe(link); } + await user.tab(); + expect(document.activeElement).toBe(languageSelector); + await user.tab(); expect(document.activeElement).toBe(searchTrigger); }); diff --git a/src/components/layout/model-atlas-docs-header.tsx b/src/components/layout/model-atlas-docs-header.tsx index a92597ab..cef6e190 100644 --- a/src/components/layout/model-atlas-docs-header.tsx +++ b/src/components/layout/model-atlas-docs-header.tsx @@ -3,6 +3,7 @@ import { Menu } from "lucide-react"; import Link from "next/link"; import { type ReactNode, useId, useState } from "react"; +import { LanguageSelector } from "@/components/layout/language-selector"; import { getPrimaryNavItems, PRIMARY_NAV_DESKTOP_CLASS, @@ -80,6 +81,7 @@ export function ModelAtlasDocsHeader({ : null}
+ {trailing}
diff --git a/src/content/messages/en/common.json b/src/content/messages/en/common.json index 935af9e3..6c032b94 100644 --- a/src/content/messages/en/common.json +++ b/src/content/messages/en/common.json @@ -17,7 +17,11 @@ "menu": "Open menu", "architecture": "Architecture", "glossary": "Glossary", - "tags": "Tags" + "tags": "Tags", + "language": "Language", + "currentLanguage": "Current", + "unavailableLanguage": "Unavailable", + "unavailableOnPage": "Unavailable on this page" }, "searchEntry": { "title": "Search", diff --git a/src/content/messages/ja/common.json b/src/content/messages/ja/common.json index 55e6e454..df3ceecf 100644 --- a/src/content/messages/ja/common.json +++ b/src/content/messages/ja/common.json @@ -17,7 +17,11 @@ "menu": "メニューを開く", "architecture": "アーキテクチャ", "glossary": "用語集", - "tags": "タグ" + "tags": "タグ", + "language": "言語", + "currentLanguage": "現在", + "unavailableLanguage": "未提供", + "unavailableOnPage": "このページでは未提供" }, "searchEntry": { "title": "検索", diff --git a/src/content/messages/vi/common.json b/src/content/messages/vi/common.json index c0ec7ae9..74b1cfe6 100644 --- a/src/content/messages/vi/common.json +++ b/src/content/messages/vi/common.json @@ -17,7 +17,11 @@ "menu": "Mở menu", "architecture": "Kiến trúc", "glossary": "Thuật ngữ", - "tags": "Thẻ" + "tags": "Thẻ", + "language": "Ngôn ngữ", + "currentLanguage": "Hiện tại", + "unavailableLanguage": "Chưa khả dụng", + "unavailableOnPage": "Chưa khả dụng trên trang này" }, "searchEntry": { "title": "Tìm kiếm", diff --git a/src/lib/content/glossary-opening-convergence.test.tsx b/src/lib/content/glossary-opening-convergence.test.tsx index e3dda633..8043320a 100644 --- a/src/lib/content/glossary-opening-convergence.test.tsx +++ b/src/lib/content/glossary-opening-convergence.test.tsx @@ -39,17 +39,19 @@ describe("glossary opening convergence", () => { async () => { const pages = await listPublishedGlossaryPages(); - for (const page of pages) { - const loadedPage = await loadLocalDocsPage({ - section: "glossary", - slug: page.slug, - }); - expectGlossaryOpeningSummaryMessage(loadedPage.messages); + await Promise.all( + pages.map(async (page) => { + const loadedPage = await loadLocalDocsPage({ + section: "glossary", + slug: page.slug, + }); + expectGlossaryOpeningSummaryMessage(loadedPage.messages); - const html = renderGlossaryDocsShell(loadedPage); - expectGlossaryOmitsOpeningSummary(html); - } + const html = renderGlossaryDocsShell(loadedPage); + expectGlossaryOmitsOpeningSummary(html); + }), + ); }, - { timeout: 10_000 }, + { timeout: 20_000 }, ); }); diff --git a/src/lib/content/localized-docs-href.ts b/src/lib/content/localized-docs-href.ts index 567a4565..a1752bbb 100644 --- a/src/lib/content/localized-docs-href.ts +++ b/src/lib/content/localized-docs-href.ts @@ -1,9 +1,7 @@ -import { isShippedLocalizedDocsSlug } from "@/lib/content/shipped-localized-docs"; import { defaultLocale, - matchLocalizedRoute, + resolveLocalizedRouteSwitch, type SiteLocale, - switchRouteLocale, } from "@/lib/i18n/locale-routing"; export function localizeDocsHref(href: string, locale: SiteLocale): string { @@ -11,17 +9,10 @@ export function localizeDocsHref(href: string, locale: SiteLocale): string { return href; } - const match = matchLocalizedRoute(href); - if (match.kind !== "matched") { + const resolved = resolveLocalizedRouteSwitch(href, locale); + if (!resolved.available) { return href; } - if ( - match.destination.surface === "docs-page" && - !isShippedLocalizedDocsSlug(match.destination.slug, locale) - ) { - return href; - } - - return switchRouteLocale(href, locale); + return resolved.href; } diff --git a/src/lib/content/shipped-localized-docs.ts b/src/lib/content/shipped-localized-docs.ts index f00a3a46..0ef0f568 100644 --- a/src/lib/content/shipped-localized-docs.ts +++ b/src/lib/content/shipped-localized-docs.ts @@ -1,4 +1,4 @@ -import { defaultLocale, type SiteLocale } from "@/lib/i18n/locale-routing"; +import { defaultLocale, type SiteLocale } from "../i18n/locale-routing"; export type NonDefaultLocale = Exclude; diff --git a/src/lib/content/ui-messages.types.ts b/src/lib/content/ui-messages.types.ts index 5e8b44fa..652f524d 100644 --- a/src/lib/content/ui-messages.types.ts +++ b/src/lib/content/ui-messages.types.ts @@ -18,6 +18,10 @@ export type UiMessages = { architecture: string; glossary: string; tags: string; + language: string; + currentLanguage: string; + unavailableLanguage: string; + unavailableOnPage: string; }; searchEntry: { title: string; diff --git a/src/lib/i18n/locale-routing.test.ts b/src/lib/i18n/locale-routing.test.ts index f0822acc..e6fedc40 100644 --- a/src/lib/i18n/locale-routing.test.ts +++ b/src/lib/i18n/locale-routing.test.ts @@ -2,9 +2,12 @@ import { describe, expect, test } from "bun:test"; import { buildLocalizedRoute, defaultLocale, + isLocalizedRouteShipped, + localeOptions, localizePath, matchLocalizedRoute, resolveLocale, + resolveLocalizedRouteSwitch, supportedLocales, switchRouteLocale, UnsupportedLocaleError, @@ -14,6 +17,11 @@ describe("locale-routing", () => { test("exports the shared locale contract", () => { expect(supportedLocales).toEqual(["en", "vi", "ja"]); expect(defaultLocale).toBe("en"); + expect(localeOptions).toEqual([ + { code: "en", label: "English" }, + { code: "vi", label: "Tiếng Việt" }, + { code: "ja", label: "日本語" }, + ]); }); test("resolveLocale defaults to english and rejects unsupported locales", () => { @@ -135,13 +143,75 @@ describe("locale-routing", () => { expect(switchRouteLocale("/docs/glossary/token", "ja")).toBe( "/ja/docs/glossary/token", ); + expect( + switchRouteLocale("/docs/glossary/token?view=compact#usage-notes", "vi"), + ).toBe("/vi/docs/glossary/token?view=compact#usage-notes"); + expect( + switchRouteLocale( + "/vi/docs/modules/grouped-query-attention?tab=graph#kv-cache", + "en", + ), + ).toBe("/docs/modules/grouped-query-attention?tab=graph#kv-cache"); expect(switchRouteLocale("/vi/tags/attention", "en")).toBe( "/tags/attention", ); expect(switchRouteLocale("/vi/tags/attention", "ja")).toBe( "/ja/tags/attention", ); - expect(switchRouteLocale("/search?tag=attention", "vi")).toBe("/vi/search"); - expect(switchRouteLocale("/search?tag=attention", "ja")).toBe("/ja/search"); + expect(switchRouteLocale("/search?tag=attention", "vi")).toBe( + "/vi/search?tag=attention", + ); + expect(switchRouteLocale("/search?tag=attention", "ja")).toBe( + "/ja/search?tag=attention", + ); + }); + + test("shared shipped-route gating distinguishes shipped docs from unshipped docs", () => { + expect( + isLocalizedRouteShipped( + { surface: "docs-page", slug: "modules/grouped-query-attention" }, + "vi", + ), + ).toBe(true); + expect( + isLocalizedRouteShipped( + { surface: "docs-page", slug: "modules/grouped-query-attention" }, + "ja", + ), + ).toBe(false); + expect(isLocalizedRouteShipped({ surface: "search" }, "ja")).toBe(true); + expect( + isLocalizedRouteShipped({ surface: "tag-page", slug: "attention" }, "ja"), + ).toBe(true); + }); + + test("resolveLocalizedRouteSwitch fails closed for unshipped localized docs destinations", () => { + expect( + resolveLocalizedRouteSwitch( + "/docs/modules/grouped-query-attention?view=graph#kv-cache", + "vi", + ), + ).toEqual({ + available: true, + destination: { + surface: "docs-page", + slug: "modules/grouped-query-attention", + }, + href: "/vi/docs/modules/grouped-query-attention?view=graph#kv-cache", + }); + + expect( + resolveLocalizedRouteSwitch( + "/docs/modules/grouped-query-attention?view=graph#kv-cache", + "ja", + ), + ).toEqual({ + available: false, + destination: { + surface: "docs-page", + slug: "modules/grouped-query-attention", + }, + reason: "unshipped-destination", + }); }); }); diff --git a/src/lib/i18n/locale-routing.ts b/src/lib/i18n/locale-routing.ts index e0b5bd18..63cae1eb 100644 --- a/src/lib/i18n/locale-routing.ts +++ b/src/lib/i18n/locale-routing.ts @@ -1,3 +1,5 @@ +import { isShippedLocalizedDocsSlug } from "../content/shipped-localized-docs"; + const LOCALE_PREFIX_PATTERN = /^[a-z]{2}(?:-[A-Z]{2})?$/; export const supportedLocales = ["en", "vi", "ja"] as const; @@ -6,6 +8,17 @@ export type SiteLocale = (typeof supportedLocales)[number]; export const defaultLocale: SiteLocale = "en"; +export type LocaleOption = { + code: SiteLocale; + label: string; +}; + +export const localeOptions: readonly LocaleOption[] = [ + { code: "en", label: "English" }, + { code: "vi", label: "Tiếng Việt" }, + { code: "ja", label: "日本語" }, +] as const; + export type LocalizedRouteDestination = | { surface: "home" } | { surface: "search" } @@ -32,6 +45,18 @@ export type LocalizedRouteMatch = pathname: string; }; +export type LocalizedRouteSwitchResult = + | { + available: true; + destination: LocalizedRouteDestination; + href: string; + } + | { + available: false; + destination: LocalizedRouteDestination | null; + reason: "unmatched-route" | "unshipped-destination"; + }; + export class UnsupportedLocaleError extends Error { readonly locale: string; @@ -214,6 +239,7 @@ export function switchRouteLocale( pathname: string, locale: SiteLocale, ): string { + const { suffix } = splitPathname(pathname); const match = matchLocalizedRoute(pathname); if (match.kind !== "matched") { @@ -223,5 +249,60 @@ export function switchRouteLocale( return localizePath(pathname, locale); } - return buildLocalizedRoute(match.destination, locale); + return `${buildLocalizedRoute(match.destination, locale)}${suffix}`; +} + +export function isLocalizedRouteShipped( + destination: LocalizedRouteDestination, + locale: SiteLocale, +): boolean { + if (locale === defaultLocale) { + return true; + } + + switch (destination.surface) { + case "docs-page": + return isShippedLocalizedDocsSlug(destination.slug, locale); + case "home": + case "search": + case "architecture-index": + case "glossary-index": + case "tags-index": + case "tag-page": + return true; + } +} + +export function resolveLocalizedRouteSwitch( + pathname: string, + locale: SiteLocale, +): LocalizedRouteSwitchResult { + const { suffix } = splitPathname(pathname); + const match = matchLocalizedRoute(pathname); + + if (match.kind !== "matched") { + if (match.kind === "unsupported-locale") { + throw new UnsupportedLocaleError(match.locale); + } + + return { + available: false, + destination: null, + reason: "unmatched-route", + }; + } + + if (!isLocalizedRouteShipped(match.destination, locale)) { + return { + available: false, + destination: match.destination, + reason: "unshipped-destination", + }; + } + + return { + available: true, + destination: match.destination, + href: `${buildLocalizedRoute(match.destination, locale)}${suffix}`, + }; } diff --git a/src/tests/a11y/primary-navigation.a11y.test.tsx b/src/tests/a11y/primary-navigation.a11y.test.tsx index a99aed72..323e8b43 100644 --- a/src/tests/a11y/primary-navigation.a11y.test.tsx +++ b/src/tests/a11y/primary-navigation.a11y.test.tsx @@ -1,4 +1,3 @@ -import "./mock-navigation"; import { afterEach, beforeAll, describe, expect, test } from "bun:test"; import { cleanup, fireEvent, screen, within } from "@testing-library/react"; import { act } from "react"; @@ -13,6 +12,7 @@ import { renderWithAppProviders, restoreFetchMock, } from "@/tests/a11y/render"; +import { resetMockNavigation, setMockPathname } from "./mock-navigation"; describe("primary navigation accessibility smoke", () => { beforeAll(() => { @@ -22,6 +22,7 @@ describe("primary navigation accessibility smoke", () => { afterEach(() => { cleanup(); restoreFetchMock(); + resetMockNavigation(); }); test("exposes nav landmark, accessible link names, keyboard focus, and no serious axe violations", async () => { @@ -148,4 +149,76 @@ describe("primary navigation accessibility smoke", () => { await expectNoSeriousAxeViolations(header ?? document.body); }); + + test("header exposes a keyboard-focusable language selector with the current locale selected", async () => { + await installDocsSearchFetchMock(); + const context = await loadAppTestContext(); + await act(async () => { + await renderWithAppProviders( + , + { context }, + ); + }); + + const selector = screen.getByRole("combobox", { + name: context.messages.nav.language, + }) as HTMLSelectElement; + const selectorLabel = selector.closest("div")?.querySelector("label"); + + expect(selector.value).toBe("ja"); + expect(selector.getAttribute("id")).toBeTruthy(); + expect(selectorLabel?.tagName).toBe("LABEL"); + expect(selectorLabel?.getAttribute("for")).toBe(selector.id); + + const options = within(selector).getAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "English", + "Tiếng Việt", + "日本語 (Current)", + ]); + expect(options.map((option) => option.getAttribute("lang"))).toEqual([ + "en", + "vi", + "ja", + ]); + + selector.focus(); + expect(document.activeElement).toBe(selector); + + const header = document.querySelector("header"); + await expectNoSeriousAxeViolations(header ?? document.body); + }); + + test("header exposes unavailable locale state with disabled semantics on unshipped docs routes", async () => { + await installDocsSearchFetchMock(); + const context = await loadAppTestContext(); + setMockPathname("/docs/modules/grouped-query-attention"); + await act(async () => { + await renderWithAppProviders( + , + { context }, + ); + }); + + const selector = screen.getByRole("combobox", { + name: context.messages.nav.language, + }); + const options = within(selector).getAllByRole("option"); + const japaneseOption = options.find((option) => + option.textContent?.includes("日本語"), + ); + const unavailableStatus = screen.getByText( + "Unavailable on this page: 日本語", + ); + + expect(japaneseOption?.textContent).toBe("日本語 (Unavailable)"); + expect(japaneseOption?.hasAttribute("disabled")).toBe(true); + expect(unavailableStatus.getAttribute("role")).toBe("status"); + expect(selector.getAttribute("aria-describedby")).toBe( + unavailableStatus.id, + ); + + const header = document.querySelector("header"); + await expectNoSeriousAxeViolations(header ?? document.body); + }); }); diff --git a/src/tests/a11y/render.tsx b/src/tests/a11y/render.tsx index e387a159..686be58b 100644 --- a/src/tests/a11y/render.tsx +++ b/src/tests/a11y/render.tsx @@ -62,6 +62,8 @@ type RenderWithProvidersOptions = Omit & { context?: AppTestContext; /** When set, replaces the default ModelAtlas search dialog (use `() => null` for nav-only tests). */ SearchDialog?: ComponentType; + pathname?: string; + searchParams?: URLSearchParams; }; export async function renderWithAppProviders( @@ -84,7 +86,10 @@ export async function renderWithAppProviders( }; return ( - + { expect(messages.nav.search).toBe("Search"); expect(messages.nav.menu).toBe("Open menu"); expect(messages.nav.architecture).toBe("Architecture"); + expect(messages.nav.language).toBe("Language"); + expect(messages.nav.currentLanguage).toBe("Current"); + expect(messages.nav.unavailableLanguage).toBe("Unavailable"); + expect(messages.nav.unavailableOnPage).toBe("Unavailable on this page"); expect(messages.searchEntry.title).toBe("Search"); expect(messages.architectureIndex.title).toBe("Architecture"); expect(messages.glossaryIndex.title).toBe("Glossary"); @@ -34,6 +38,8 @@ describe("loadUiMessages shell keys", () => { it("loads shipped vietnamese shell copy when vi shared messages are available", async () => { const messages = await loadUiMessages("vi"); expect(messages.nav.home).toBe("Trang chủ"); + expect(messages.nav.language).toBe("Ngôn ngữ"); + expect(messages.nav.unavailableLanguage).toBe("Chưa khả dụng"); expect(messages.searchEntry.title).toBe("Tìm kiếm"); expect(messages.tagsIndex.title).toBe("Thẻ"); }); @@ -41,6 +47,8 @@ describe("loadUiMessages shell keys", () => { it("loads shipped japanese shell copy when ja shared messages are available", async () => { const messages = await loadUiMessages("ja"); expect(messages.nav.home).toBe("ホーム"); + expect(messages.nav.language).toBe("言語"); + expect(messages.nav.unavailableOnPage).toBe("このページでは未提供"); expect(messages.searchEntry.title).toBe("検索"); expect(messages.tagsIndex.title).toBe("タグ"); expect(messages.shell.sidebarTitle).toBe("リファレンス");