diff --git a/src/i18n/resolver.test.ts b/src/i18n/resolver.test.ts index 2c0fcf0f..8a48d657 100644 --- a/src/i18n/resolver.test.ts +++ b/src/i18n/resolver.test.ts @@ -31,12 +31,18 @@ describe("isLanguage", () => { }); describe("resolveLocaleFromPath", () => { - it("extracts BCP-47 locale from the first path segment", () => { + it("extracts Angular country-language locale from the first path segment", () => { expect(resolveLocaleFromPath("/ru-ru/onlineboard")).toBe("ru-ru"); expect(resolveLocaleFromPath("/en-en/onlineboard/flight/SU100")).toBe("en-en"); expect(resolveLocaleFromPath("/de-de/schedule")).toBe("de-de"); }); + it("accepts mixed country-language URLs and resolves language from the second part", () => { + expect(resolveLocaleFromPath("/ru-en/onlineboard")).toBe("ru-en"); + expect(resolveLocaleFromPath("/ru-de/schedule")).toBe("ru-de"); + expect(resolveLocaleFromPath("/ad-fr/onlineboard")).toBe("ad-fr"); + }); + it("auto-promotes a bare short language code to its BCP-47 cousin", () => { expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru-ru"); expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en-en"); @@ -47,6 +53,8 @@ describe("resolveLocaleFromPath", () => { expect(resolveLocaleFromPath("/onlineboard")).toBeNull(); expect(resolveLocaleFromPath("/xx/onlineboard")).toBeNull(); expect(resolveLocaleFromPath("/xx-xx/onlineboard")).toBeNull(); + expect(resolveLocaleFromPath("/russia-en/onlineboard")).toBeNull(); + expect(resolveLocaleFromPath("/ru-eng/onlineboard")).toBeNull(); expect(resolveLocaleFromPath("/")).toBeNull(); expect(resolveLocaleFromPath("")).toBeNull(); }); @@ -58,7 +66,7 @@ describe("resolveLocaleFromPath", () => { }); describe("stripLocaleFromPath", () => { - it("strips BCP-47 locale and returns the rest", () => { + it("strips Angular country-language locale and returns the rest", () => { expect(stripLocaleFromPath("/ru-ru/onlineboard")).toEqual({ locale: "ru-ru", rest: "/onlineboard", @@ -67,6 +75,10 @@ describe("stripLocaleFromPath", () => { locale: "en-en", rest: "/onlineboard/flight/SU100", }); + expect(stripLocaleFromPath("/ru-en/onlineboard")).toEqual({ + locale: "ru-en", + rest: "/onlineboard", + }); }); it("auto-promotes short codes when stripping", () => { diff --git a/src/i18n/resolver.ts b/src/i18n/resolver.ts index 498c06e0..3377dd81 100644 --- a/src/i18n/resolver.ts +++ b/src/i18n/resolver.ts @@ -2,25 +2,16 @@ * Locale codes used in URLs vs the short language code used for i18n * file lookup, API path segment, and `Accept-Language` header. * - * Mirrors Angular's `LocalizationService`: URL is BCP-47 (`/ru-ru/`, - * `/en-en/`, `/zh-zh/`...), backend + translation files use the short - * 2-letter language part only (`ru`, `en`, `zh`...). This split keeps - * the customer's URL contract while reusing a single set of locale + * Mirrors Angular's `LocalizationService`: URL is `/{country}-{language}` + * (`/ru-ru/`, `/ru-en/`, `/ru-de/`...), while backend + translation files + * use the second 2-letter segment only (`ru`, `en`, `de`...). This split + * keeps the customer's URL contract while reusing a single set of locale * resources. */ export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "de"; -export type LocaleCode = - | "ru-ru" - | "en-en" - | "es-es" - | "fr-fr" - | "it-it" - | "ja-ja" - | "ko-ko" - | "zh-zh" - | "de-de"; +export type LocaleCode = `${string}-${Language}`; export const LANGUAGES: readonly Language[] = [ "ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de", @@ -34,13 +25,11 @@ export const DEFAULT_LOCALE_CODE: LocaleCode = "ru-ru"; export const DEFAULT_LANGUAGE: Language = "ru"; const languageSet: ReadonlySet = new Set(LANGUAGES); -const localeCodeSet: ReadonlySet = new Set(LOCALE_CODES); +const URL_LOCALE_PATTERN = /^([a-z]{2})-([a-z]{2})$/; -// Angular's URL contract is `/{country}-{language}` where the two -// halves repeat the same 2-letter language code (see -// `ClientApp/src/app/shared/services/localization.service.ts` — -// `Country = baseHref[1..3]`, `Language = baseHref[4..6]`). We mirror -// that exact shape so the React MF remote shares Angular's URL surface. +// `languageToLocale` returns the canonical same-language URL used for +// generated links, while `normalizeLocaleParam` below also accepts Angular's +// mixed country-language URLs such as `/ru-en`. const LANGUAGE_TO_LOCALE_CODE: Record = { ru: "ru-ru", en: "en-en", @@ -58,15 +47,16 @@ export function isLanguage(x: string): x is Language { } export function isLocaleCode(x: string): x is LocaleCode { - return localeCodeSet.has(x); + const match = URL_LOCALE_PATTERN.exec(x); + return match ? isLanguage(match[2] ?? "") : false; } /** - * Extract the language part from a BCP-47 locale code. - * `localeToLanguage("en-en")` → `"en"`. + * Extract the language part from an Angular URL locale code. + * `localeToLanguage("ru-en")` → `"en"`. */ export function localeToLanguage(code: LocaleCode): Language { - return code.slice(0, 2) as Language; + return code.split("-")[1] as Language; } /** @@ -79,10 +69,10 @@ export function languageToLocale(lang: Language): LocaleCode { } /** - * Read the locale code from `:lang` URL params. Accepts both BCP-47 - * (`ru-ru`) and bare short codes (`ru`) — the short form is promoted - * to its canonical BCP-47 cousin so legacy / direct API consumers - * keep working during migration. + * Read the locale code from `:lang` URL params. Accepts Angular + * country-language URLs (`ru-en`) and bare short codes (`en`) — the short + * form is promoted to its canonical same-language URL (`en-en`) so legacy / + * direct API consumers keep working during migration. */ export function normalizeLocaleParam(raw: string | undefined): LocaleCode | null { if (!raw) return null; diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 064addd6..35870bed 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -50,6 +50,29 @@ test.describe("Smoke tests", () => { await expect(page.getByText("en-en", { exact: true })).toBeVisible(); }); + test("/ru-en/onlineboard uses English language with Russia country prefix", async ({ + page, + consoleMessages, + }) => { + await page.goto("/ru-en/onlineboard?_preferredLanguage=en&_preferredLocale=ruh"); + await page.waitForLoadState("domcontentloaded"); + + await expect(page).toHaveURL(/\/ru-en\/onlineboard/); + await expect(page.locator("h1")).toHaveText("Online Timetable", { + timeout: 10000, + }); + await expect(page.getByTestId("standalone-header")).toContainText("Services", { + timeout: 15000, + }); + await expect(page.getByTestId("standalone-header")).toContainText("EN", { + timeout: 15000, + }); + await expect(page.locator('afl-component[data-component="Footer"]')).toContainText( + "Contact us", + { timeout: 15000 }, + ); + }); + test("/xx/smoke shows 404 or unknown locale message", async ({ page, consoleMessages }) => { await page.goto("/xx/smoke"); await page.waitForLoadState("domcontentloaded");