mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-04 05:18:13 +02:00
i18n: add language toggle, neutrality policy, and codeowner gate (#238)
PR #226 landed the i18n infrastructure and Chinese (zh-CN) translations. This follow-up adds the safeguards that make accepting community translations sustainable without exposing the project to subtle state-aligned framing in future translation PRs. Changes: frontend/src/i18n/index.tsx (renamed from .ts) - Add LOCALES registry: a single source of truth for available languages and their NATIVE display names ("English", "中文 (简体)"). Adding a new language is now a one-entry change here plus a JSON file. - Add isLocale() guard so an unknown value in localStorage falls through to navigator.language detection instead of corrupting state. - File renamed to .tsx because it contains JSX. Next.js tolerated JSX in .ts but Vite/Oxc (used by vitest) does not. frontend/src/components/SettingsPanel.tsx Add a UI language picker to the Settings header — a small <select> populated from LOCALES. Users no longer need the dev console to switch languages. Locale change remains 100% client-side (localStorage), no network call, no telemetry. CONTRIBUTING.md (new) Documents the translation-neutrality requirement that applies symmetrically to all source countries: - Translations must be technically faithful to the English source. - Substitutions aligned with state propaganda from ANY country (PRC, Russia, US, EU, etc.) will be rejected. - The test is: "would a translator working strictly from the English source produce this rendering?" Also explains how translation PRs are reviewed and how to add a new language. .github/CODEOWNERS (new) Auto-requests maintainer review on: - /frontend/src/i18n/ (translation safety) - /backend/auth.py, /backend/routers/wormhole.py, /backend/services/mesh/, /backend/services/fetchers/ (the same paths recent security audits flagged as sensitive) - /.github/workflows/, /.gitlab-ci.yml, /docker-compose*.yml, /helm/ (build/deploy) - /CONTRIBUTING.md, /.github/CODEOWNERS (policy itself) frontend/src/__tests__/i18n/i18nProvider.test.tsx (new, 8 tests) Locks in the i18n contract: - LOCALES has both en and zh-CN with non-empty native labels - Default English when navigator is English - Auto-detect zh-CN when navigator language starts with "zh" - localStorage preference overrides auto-detect - setLocale persists to localStorage - Unknown stored locale falls back to auto-detect - Renders a real zh-CN translation (catches large-scale translation removal in future PRs) - Missing key falls back to the key itself Note: i18n/index.tsx, the language toggle UI, the translation policy, and the test suite together form a defense-in-depth setup. The structural safety guarantee (no network calls, static JSON bundled at build) is intact; this PR makes the social contract around translations explicit and enforceable via branch protection on CODEOWNERS-marked paths. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { I18nProvider, LOCALES, useTranslation, type Locale } from '@/i18n';
|
||||
|
||||
/**
|
||||
* Renders a tiny consumer so we can drive the I18nContext from tests.
|
||||
*/
|
||||
function Probe({ keyToRender }: { keyToRender: string }) {
|
||||
const { locale, setLocale, t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
<span data-testid="translated">{t(keyToRender)}</span>
|
||||
<button onClick={() => setLocale('zh-CN')} data-testid="to-zh">go zh</button>
|
||||
<button onClick={() => setLocale('en')} data-testid="to-en">go en</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('I18nProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('exposes a non-empty LOCALES registry with en and zh-CN', () => {
|
||||
const codes = LOCALES.map((l) => l.code);
|
||||
expect(codes).toContain('en');
|
||||
expect(codes).toContain('zh-CN');
|
||||
// Native labels — used by the language picker. These must be set
|
||||
// so the picker shows the native language name regardless of
|
||||
// current UI locale.
|
||||
for (const entry of LOCALES) {
|
||||
expect(entry.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to English when no localStorage and English browser', () => {
|
||||
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true });
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Probe keyToRender="settings.title" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en');
|
||||
});
|
||||
|
||||
it('auto-detects zh-CN when browser language starts with "zh"', () => {
|
||||
Object.defineProperty(navigator, 'language', { value: 'zh-TW', configurable: true });
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Probe keyToRender="settings.title" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// "zh-TW" should match the zh prefix and resolve to our zh-CN bundle
|
||||
// (we ship only one Chinese variant for now).
|
||||
expect(screen.getByTestId('locale').textContent).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('honors a previously saved localStorage choice over auto-detect', () => {
|
||||
Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true });
|
||||
localStorage.setItem('sb_locale', 'en');
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Probe keyToRender="settings.title" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en');
|
||||
});
|
||||
|
||||
it('persists setLocale to localStorage', () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Probe keyToRender="settings.title" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByTestId('to-zh').click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('zh-CN');
|
||||
expect(localStorage.getItem('sb_locale')).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('falls back to auto-detect when localStorage holds an unknown locale', () => {
|
||||
// Pre-poison localStorage with a value that isn't in LOCALES. The
|
||||
// isLocale guard at provider init should ignore it and fall through
|
||||
// to navigator.language detection.
|
||||
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true });
|
||||
localStorage.setItem('sb_locale', 'klingon' as unknown as Locale);
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Probe keyToRender="settings.title" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en');
|
||||
});
|
||||
|
||||
it('renders a real translated string from the zh-CN bundle', () => {
|
||||
Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true });
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Probe keyToRender="settings.title" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The zh-CN bundle has settings.title = "设置". If this assertion
|
||||
// ever fails after a translation PR, it's a signal that the
|
||||
// translation surface was significantly altered.
|
||||
expect(screen.getByTestId('translated').textContent).toBe('设置');
|
||||
});
|
||||
|
||||
it('falls back to the key when a translation is missing', () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Probe keyToRender="this.key.intentionally.does.not.exist" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('translated').textContent).toBe(
|
||||
'this.key.intentionally.does.not.exist',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user