Files
Ilia Denisov 9101aba816 phase 7+: i18n primitive + login language picker + autocomplete-off
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.

Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.

Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.

Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
  body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:14:40 +02:00

108 lines
3.4 KiB
TypeScript

// Unit tests for the lightweight i18n primitive in
// `src/lib/i18n/index.svelte.ts`. The locale singleton is reset
// between cases through `resetForTests`; the default constructor
// derives the locale from JSDOM's `navigator.language`, so the
// reset takes an explicit value when a case needs a deterministic
// starting state.
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
DEFAULT_LOCALE,
SUPPORTED_LOCALES,
detectInitialLocale,
i18n,
type Locale,
} from "../src/lib/i18n/index.svelte";
beforeEach(() => {
i18n.resetForTests("en");
});
afterEach(() => {
i18n.resetForTests("en");
});
describe("detectInitialLocale", () => {
test("matches the first supported primary subtag", () => {
expect(detectInitialLocale(["ru-RU", "en-US"])).toBe("ru");
expect(detectInitialLocale(["en-GB", "ru"])).toBe("en");
expect(detectInitialLocale(["RU"])).toBe("ru");
});
test("skips unsupported tags and falls back to the default locale", () => {
expect(detectInitialLocale(["fr-FR", "de-DE", "ja"])).toBe(DEFAULT_LOCALE);
expect(detectInitialLocale([])).toBe(DEFAULT_LOCALE);
});
test("ignores whitespace and casing", () => {
expect(detectInitialLocale([" En-us "])).toBe("en");
expect(detectInitialLocale(["RU_ru"])).toBe("ru");
});
});
describe("SUPPORTED_LOCALES", () => {
test("each entry exposes a code, native name and translation table", () => {
for (const entry of SUPPORTED_LOCALES) {
expect(entry.code).toMatch(/^[a-z]{2}$/);
expect(entry.nativeName.length).toBeGreaterThan(0);
expect(entry.translations["login.title"].length).toBeGreaterThan(0);
}
});
test("native names cover the two phase-7 locales", () => {
const codes = SUPPORTED_LOCALES.map((l) => l.code);
const names = SUPPORTED_LOCALES.map((l) => l.nativeName);
expect(codes).toEqual(["en", "ru"]);
expect(names).toEqual(["English", "Русский"]);
});
});
describe("i18n.t", () => {
test("returns the active locale's translation", () => {
i18n.setLocale("en");
expect(i18n.t("login.title")).toBe("sign in to Galaxy");
i18n.setLocale("ru");
expect(i18n.t("login.title")).toBe("вход в Galaxy");
});
test("returns the key itself for an unknown identifier", () => {
// `t` is typed `TranslationKey`, but bracket access at runtime
// gracefully handles unknown keys — the fallback chain tries
// the active locale, then the default locale, then the literal
// key. This is the safety net for a future locale that adds
// keys before another locale catches up.
i18n.setLocale("en");
expect(
i18n.t("does.not.exist" as unknown as Parameters<typeof i18n.t>[0]),
).toBe("does.not.exist");
});
test("interpolates {placeholder} parameters", () => {
i18n.setLocale("en");
expect(i18n.t("login.code_sent_to", { email: "x@y.z" })).toBe(
"code sent to x@y.z",
);
i18n.setLocale("ru");
expect(i18n.t("login.code_sent_to", { email: "x@y.z" })).toBe(
"код отправлен на x@y.z",
);
});
test("leaves unresolved placeholders intact", () => {
i18n.setLocale("en");
expect(i18n.t("login.code_sent_to")).toContain("{email}");
expect(i18n.t("login.code_sent_to", { other: "x" })).toContain(
"{email}",
);
});
});
describe("i18n.setLocale", () => {
test("setLocale updates the reactive state", () => {
i18n.setLocale("en");
expect(i18n.locale).toBe<Locale>("en");
i18n.setLocale("ru");
expect(i18n.locale).toBe<Locale>("ru");
});
});