## Summary Adds **Português (Brasil)** (`pt-BR`) as a supported locale: - Selectable as **Translation target language** (project settings) and as the dashboard / terminal UI language. - Full machine-drafted catalogs (`app`, `cli`, `common`), disclosed in `packages/i18n/locales/TRANSLATION_STATUS.md` following the pattern #1352 established — reviewed for glossary/register consistency (0.18% untranslated, matching only keys that are empty in `en`), but native-speaker corrections are welcome. - Brazilian Portuguese content-language detection (accent-stripped stopword list — the scorer strips diacritics before matching, so accented entries never match; `com`/`mais` deliberately omitted to avoid bare-domain `.com` and French collisions, with regression tests for both directions). - `pt`/`pt-PT` browser and environment locales resolve to `pt-BR` on all three detection paths (`FALLBACK_LNG` routing plus a `pt` branch in `normalizeToSupportedLocale`, mirroring the existing `zh` handling). - `README.pt-BR.md` + switcher links in all READMEs, docs updates (`settings-reference`, `cli-reference`, `i18n-contributing`, `--lang` help text), changeset (`minor`). Drive-by fixes bundled: `TRANSLATION_STATUS.md` was missing the `ko` row; the LanguageSelector endonym test was missing `한국어`; `docs/i18n-contributing.md` now names the two compile-enforced display maps (`LOCALE_LABELS`, `localeDisplayName`) a new locale must update; the `--lang` CLI help text no longer drifts from its validator. ## Test plan - `pnpm i18n:status` (key parity gate) green; catalogs are `i18n:sync`-idempotent. - Updated/extended suites: core `locale-settings`, i18n `config`/`parity`/`db-banner-catalog`/`i18n-gate-coverage`, dashboard `useLanguage`/`LanguageSelector`/`GeneralSection.importTranslate`/`detectContentLanguage` (incl. new pt-BR detection + bare-domain regression tests), CLI `settings`. - `pnpm verify:fast` (typecheck, build, boot smoke), `pnpm lint`, `pnpm check:changesets`, and the bounded `pnpm test` lane all green locally (the three `test:pg-gate` files fail locally only for lack of a Postgres instance; they fail identically on clean `main`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Brazilian Portuguese (Português (Brasil)) across the dashboard, terminal interface, settings, and translation tools. * Added Portuguese translations for common interface and CLI content. * Added automatic Portuguese language detection, locale normalization, and fallback support. * Added a Portuguese (Brazil) README with product, setup, and usage documentation. * **Documentation** * Updated language selectors, CLI references, settings documentation, and translation guidance. * Added Portuguese README links to translated documentation. * Added French to the documented dashboard language options. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
import type { Settings } from "@fusion/core";
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { fetchGlobalSettings, updateGlobalSettings } from "../../api";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchGlobalSettings: vi.fn(),
|
|
updateGlobalSettings: vi.fn(),
|
|
}));
|
|
|
|
// Mock the i18n module so importing the hook does not boot the real i18next
|
|
// instance (which would fire the dynamic catalog backend, unavailable in jsdom).
|
|
vi.mock("../../i18n", () => ({
|
|
LANGUAGE_STORAGE_KEY: "kb-dashboard-language",
|
|
default: {},
|
|
}));
|
|
|
|
type Handler = (lng: string) => void;
|
|
const listeners: Record<string, Handler[]> = {};
|
|
const i18nMock = {
|
|
resolvedLanguage: "en",
|
|
language: "en",
|
|
changeLanguage: vi.fn(async (lng: string) => {
|
|
i18nMock.resolvedLanguage = lng;
|
|
i18nMock.language = lng;
|
|
(listeners.languageChanged ?? []).forEach((h) => h(lng));
|
|
}),
|
|
on: vi.fn((event: string, handler: Handler) => {
|
|
(listeners[event] ??= []).push(handler);
|
|
}),
|
|
off: vi.fn((event: string, handler: Handler) => {
|
|
listeners[event] = (listeners[event] ?? []).filter((h) => h !== handler);
|
|
}),
|
|
};
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ i18n: i18nMock, t: (key: string, def?: string) => def ?? key }),
|
|
}));
|
|
|
|
const mockFetch = vi.mocked(fetchGlobalSettings);
|
|
const mockUpdate = vi.mocked(updateGlobalSettings);
|
|
|
|
// Import after mocks are registered.
|
|
const { useLanguage } = await import("../useLanguage");
|
|
|
|
const LANGUAGE_STORAGE_KEY = "kb-dashboard-language";
|
|
|
|
describe("useLanguage", () => {
|
|
let store: Record<string, string> = {};
|
|
|
|
beforeEach(() => {
|
|
store = {};
|
|
for (const k of Object.keys(listeners)) delete listeners[k];
|
|
i18nMock.resolvedLanguage = "en";
|
|
i18nMock.language = "en";
|
|
i18nMock.changeLanguage.mockClear();
|
|
mockFetch.mockReset();
|
|
mockUpdate.mockReset();
|
|
mockFetch.mockImplementation(() => new Promise(() => {})); // hydration pending by default
|
|
mockUpdate.mockResolvedValue({} as Settings);
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
vi.stubGlobal("localStorage", {
|
|
getItem: (k: string) => store[k] ?? null,
|
|
setItem: (k: string, v: string) => {
|
|
store[k] = v;
|
|
},
|
|
removeItem: (k: string) => {
|
|
delete store[k];
|
|
},
|
|
});
|
|
});
|
|
|
|
it("changes language in place and writes through to localStorage + server", () => {
|
|
const { result } = renderHook(() => useLanguage());
|
|
act(() => result.current.setLanguage("fr"));
|
|
|
|
expect(i18nMock.changeLanguage).toHaveBeenCalledWith("fr");
|
|
expect(store[LANGUAGE_STORAGE_KEY]).toBe("fr");
|
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
|
expect(mockUpdate).toHaveBeenCalledWith({ language: "fr" });
|
|
expect(result.current.language).toBe("fr");
|
|
});
|
|
|
|
it("exposes the supported locale list", () => {
|
|
const { result } = renderHook(() => useLanguage());
|
|
expect([...result.current.supportedLocales]).toEqual(["en", "zh-CN", "zh-TW", "fr", "es", "ko", "pt-BR"]);
|
|
});
|
|
|
|
it("hydrates from server settings when no local choice exists", async () => {
|
|
mockFetch.mockResolvedValue({ language: "es" } as Settings);
|
|
renderHook(() => useLanguage());
|
|
await waitFor(() => expect(i18nMock.changeLanguage).toHaveBeenCalledWith("es"));
|
|
});
|
|
|
|
it("does not let server settings override a local choice", async () => {
|
|
store[LANGUAGE_STORAGE_KEY] = "fr";
|
|
mockFetch.mockResolvedValue({ language: "es" } as Settings);
|
|
renderHook(() => useLanguage());
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(i18nMock.changeLanguage).not.toHaveBeenCalledWith("es");
|
|
});
|
|
|
|
it("does not let in-flight server hydration override a concurrent user choice", async () => {
|
|
let resolveFetch!: (s: Settings) => void;
|
|
mockFetch.mockImplementation(() => new Promise<Settings>((r) => { resolveFetch = r; }));
|
|
const { result } = renderHook(() => useLanguage());
|
|
// User picks fr while the hydration fetch is still pending (sets userSetRef).
|
|
act(() => result.current.setLanguage("fr"));
|
|
i18nMock.changeLanguage.mockClear();
|
|
// Now the server responds with a different locale — must be ignored.
|
|
resolveFetch({ language: "es" } as Settings);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(i18nMock.changeLanguage).not.toHaveBeenCalledWith("es");
|
|
});
|
|
|
|
it("adopts a language change made in another tab via the storage event", () => {
|
|
renderHook(() => useLanguage());
|
|
i18nMock.changeLanguage.mockClear();
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY, newValue: "zh-TW" }),
|
|
);
|
|
});
|
|
expect(i18nMock.changeLanguage).toHaveBeenCalledWith("zh-TW");
|
|
});
|
|
|
|
it("clearLanguage removes the local key, clears the server setting, and re-detects", () => {
|
|
store[LANGUAGE_STORAGE_KEY] = "fr";
|
|
vi.stubGlobal("navigator", { languages: ["es-419", "en-US"], language: "es-419" });
|
|
const { result } = renderHook(() => useLanguage());
|
|
expect(result.current.hasExplicitChoice).toBe(true);
|
|
|
|
act(() => result.current.clearLanguage());
|
|
|
|
expect(store[LANGUAGE_STORAGE_KEY]).toBeUndefined();
|
|
expect(mockUpdate).toHaveBeenCalledWith({ language: null });
|
|
expect(i18nMock.changeLanguage).toHaveBeenCalledWith("es"); // re-detected
|
|
expect(result.current.hasExplicitChoice).toBe(false);
|
|
});
|
|
|
|
it("setLanguage marks the choice explicit; clearLanguage unmarks it", () => {
|
|
const { result } = renderHook(() => useLanguage());
|
|
expect(result.current.hasExplicitChoice).toBe(false);
|
|
act(() => result.current.setLanguage("fr"));
|
|
expect(result.current.hasExplicitChoice).toBe(true);
|
|
act(() => result.current.clearLanguage());
|
|
expect(result.current.hasExplicitChoice).toBe(false);
|
|
});
|
|
|
|
it("degrades gracefully when localStorage is unavailable", () => {
|
|
vi.stubGlobal("localStorage", {
|
|
getItem: () => null,
|
|
setItem: () => {
|
|
throw new Error("denied");
|
|
},
|
|
removeItem: () => {},
|
|
});
|
|
const { result } = renderHook(() => useLanguage());
|
|
expect(() => act(() => result.current.setLanguage("zh-TW"))).not.toThrow();
|
|
expect(mockUpdate).toHaveBeenCalledWith({ language: "zh-TW" });
|
|
});
|
|
});
|