mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
bee500880e
Finishes the plural work the previous wip commit started. t() picks a count string's form by the language's own Intl.PluralRules, and every locale now spells out the forms its language reaches with whole numbers, about 160 count strings in all: one, few and many in Russian, Ukrainian and Polish, one and few in Czech and Slovak, five forms in Arabic, one in the others, none in Japanese, Korean, Chinese, Thai, Vietnamese and Indonesian. Each language was translated and then reviewed on its own. English no longer reads "1 files", "1 updates available" or "photo(s)". Count labels shown next to a number now take that number too: the atlas and admin statistics, the trip cards, journey and shared trip pills, packing templates, receipts, storage usage, the Studio export note. A heading that shared its key with a counted label keeps a key of its own (the boarding pass heading, the Bookings tab), so languages that put a noun after a number in the singular keep a plural heading. The plugin hosts chip passed its count through .replace() after t() and never got a form; three toasts sent the count as text. Mail and push texts for shared photos and suppressed storage failures pick their form in code through pluralForm. A whole number sent as text counts too, which is how in-app notification params arrive, and the plural rule follows the language of the strings on screen while another one still loads. The parity check knows which forms each language needs and refuses missing and impossible ones. It also reads keys that contain a colon now: it skipped them, which hid 58 missing plugin permission texts in Catalan and 35 in Vietnamese, both translated. The client parity test covers every language of the registry instead of a hand-kept list of 19, and the placeholder spec lets a form spell its number out only where its category is that one number. Brazilian Portuguese error screens were in Breton, and an Arabic dashboard label held Hebrew text.
227 lines
9.4 KiB
TypeScript
227 lines
9.4 KiB
TypeScript
// FE-COMP-I18NCTX-001 to FE-COMP-I18NCTX-018 (plus -003b / -013b)
|
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
import { SUPPORTED_LANGUAGES, TranslationProvider, useTranslation } from '../../../src/i18n/TranslationContext';
|
|
import { TransHtml } from '../../../src/i18n/TransHtml';
|
|
import { useSettingsStore } from '../../../src/store/settingsStore';
|
|
import { resetAllStores, seedStore } from '../../helpers/store';
|
|
|
|
// The provider swaps the whole string table via a dynamic import per locale, so
|
|
// these tests assert on what the consumer actually sees rather than on the
|
|
// loader plumbing: English is synchronous, everything else arrives one tick later.
|
|
|
|
function Probe({ tKey, params }: { tKey: string; params?: Record<string, string | number> }) {
|
|
const { t, language, locale } = useTranslation();
|
|
return (
|
|
<div>
|
|
<span data-testid="value">{t(tKey, params)}</span>
|
|
<span data-testid="language">{language}</span>
|
|
<span data-testid="locale">{locale}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HtmlProbe({ tKey, params }: { tKey: string; params?: Record<string, string | number> }) {
|
|
const { tHtml } = useTranslation();
|
|
return <span data-testid="raw">{tHtml(tKey, params)}</span>;
|
|
}
|
|
|
|
function withLanguage(language: string) {
|
|
seedStore(useSettingsStore, { settings: { language } });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
document.documentElement.lang = '';
|
|
document.documentElement.dir = '';
|
|
});
|
|
|
|
describe('TranslationProvider', () => {
|
|
it('FE-COMP-I18NCTX-001: resolves a key from the English table', () => {
|
|
render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
expect(screen.getByTestId('value').textContent).toBe('Save');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-002: returns the key itself when nothing matches', () => {
|
|
render(<TranslationProvider><Probe tKey="does.not.exist" /></TranslationProvider>);
|
|
expect(screen.getByTestId('value').textContent).toBe('does.not.exist');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-003: substitutes every occurrence of a placeholder', () => {
|
|
render(<TranslationProvider><Probe tKey="common.hoursAgo" params={{ count: 5 }} /></TranslationProvider>);
|
|
expect(screen.getByTestId('value').textContent).toContain('5');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-003b: inserts a value containing $ patterns literally', () => {
|
|
render(
|
|
<TranslationProvider>
|
|
<Probe tKey="journey.frontpage.suggestionText" params={{ title: 'A$&B $1 $`' }} />
|
|
</TranslationProvider>,
|
|
);
|
|
// A trip really can be named this, and `$&` as a replacement string would
|
|
// paste the matched placeholder back in.
|
|
expect(screen.getByTestId('value').textContent).toContain('A$&B $1 $`');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-004: defaults to English with the matching locale', () => {
|
|
render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
expect(screen.getByTestId('language').textContent).toBe('en');
|
|
expect(screen.getByTestId('locale').textContent).toBe('en-US');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-005: loads another locale and exposes its language and locale', async () => {
|
|
withLanguage('de');
|
|
render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
|
|
expect(screen.getByTestId('language').textContent).toBe('de');
|
|
await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('Speichern'));
|
|
expect(screen.getByTestId('locale').textContent).toBe('de-DE');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-006: falls back to the English string for a key a locale is missing', async () => {
|
|
withLanguage('de');
|
|
render(<TranslationProvider><Probe tKey="does.not.exist" /></TranslationProvider>);
|
|
await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('does.not.exist'));
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-007: writes lang and ltr direction onto the html element', () => {
|
|
withLanguage('de');
|
|
render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
|
|
expect(document.documentElement.lang).toBe('de');
|
|
expect(document.documentElement.dir).toBe('ltr');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-008: switches the html element to rtl for Arabic', async () => {
|
|
withLanguage('ar');
|
|
render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
|
|
expect(document.documentElement.lang).toBe('ar');
|
|
expect(document.documentElement.dir).toBe('rtl');
|
|
await waitFor(() => expect(screen.getByTestId('value').textContent).not.toBe('Save'));
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-009: keeps the English table for an unknown language code', () => {
|
|
withLanguage('klingon');
|
|
render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
|
|
expect(screen.getByTestId('value').textContent).toBe('Save');
|
|
expect(screen.getByTestId('language').textContent).toBe('klingon');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-010: treats an empty language setting as English', () => {
|
|
withLanguage('');
|
|
render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
expect(screen.getByTestId('language').textContent).toBe('en');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-011: a locale arriving after a second switch does not win', async () => {
|
|
withLanguage('de');
|
|
render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
|
|
// Switch back before the German chunk lands — the cancelled loader must not
|
|
// overwrite the English table when its promise finally resolves.
|
|
act(() => withLanguage('en'));
|
|
await act(async () => { await Promise.resolve(); });
|
|
|
|
expect(screen.getByTestId('language').textContent).toBe('en');
|
|
expect(screen.getByTestId('value').textContent).toBe('Save');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-011a: every supported language resolves through its own chunk', async () => {
|
|
for (const { value } of SUPPORTED_LANGUAGES) {
|
|
withLanguage(value);
|
|
const view = render(<TranslationProvider><Probe tKey="common.save" /></TranslationProvider>);
|
|
await act(async () => { await Promise.resolve(); });
|
|
|
|
expect(screen.getByTestId('language').textContent).toBe(value);
|
|
// Whatever the locale ships, the key must resolve to something other than itself.
|
|
expect(screen.getByTestId('value').textContent).not.toBe('common.save');
|
|
view.unmount();
|
|
}
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-018: plural forms follow the language of the strings on screen, not the one still loading', async () => {
|
|
withLanguage('ru');
|
|
render(<TranslationProvider><Probe tKey="places.count" params={{ count: 21 }} /></TranslationProvider>);
|
|
|
|
// English strings until the Russian chunk arrives, read with the English
|
|
// rule: 21 is "other" there, while Russian's rule would pick English's .one.
|
|
expect(screen.getByTestId('value').textContent).toBe('21 places');
|
|
await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('21 место'));
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-012: the default context outside a provider echoes the key', () => {
|
|
render(<Probe tKey="common.save" />);
|
|
expect(screen.getByTestId('value').textContent).toBe('common.save');
|
|
expect(screen.getByTestId('locale').textContent).toBe('en-US');
|
|
});
|
|
});
|
|
|
|
describe('tHtml', () => {
|
|
it('FE-COMP-I18NCTX-013: escapes an interpolated value before substitution', () => {
|
|
render(
|
|
<TranslationProvider>
|
|
<HtmlProbe tKey="journey.frontpage.suggestionText" params={{ title: '<img src=x onerror=alert(1)>' }} />
|
|
</TranslationProvider>,
|
|
);
|
|
|
|
const raw = screen.getByTestId('raw').textContent ?? '';
|
|
expect(raw).not.toContain('<img');
|
|
expect(raw).toContain('<img');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-013b: keeps $ patterns in an escaped value literal', () => {
|
|
render(
|
|
<TranslationProvider>
|
|
<HtmlProbe tKey="journey.frontpage.suggestionText" params={{ title: 'A$&B' }} />
|
|
</TranslationProvider>,
|
|
);
|
|
|
|
expect(screen.getByTestId('raw').textContent).toContain('A$&B');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-014: returns the key unchanged when it is unknown', () => {
|
|
render(<TranslationProvider><HtmlProbe tKey="nope.nope" /></TranslationProvider>);
|
|
expect(screen.getByTestId('raw').textContent).toBe('nope.nope');
|
|
});
|
|
});
|
|
|
|
describe('TransHtml', () => {
|
|
it('FE-COMP-I18NCTX-015: renders as a span by default and keeps allowed inline markup', () => {
|
|
const { container } = render(
|
|
<TranslationProvider>
|
|
<TransHtml html="journey.frontpage.suggestionText" params={{ title: 'Japan 2026' }} />
|
|
</TranslationProvider>,
|
|
);
|
|
|
|
const el = container.firstElementChild!;
|
|
expect(el.tagName).toBe('SPAN');
|
|
expect(el.textContent).toContain('Japan 2026');
|
|
expect(el.querySelector('script')).toBeNull();
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-016: honours the as, className and id props', () => {
|
|
const { container } = render(
|
|
<TranslationProvider>
|
|
<TransHtml as="p" className="text-body" id="banner" html="common.save" />
|
|
</TranslationProvider>,
|
|
);
|
|
|
|
const el = container.firstElementChild!;
|
|
expect(el.tagName).toBe('P');
|
|
expect(el).toHaveClass('text-body');
|
|
expect(el.id).toBe('banner');
|
|
});
|
|
|
|
it('FE-COMP-I18NCTX-017: neutralises a script tag smuggled in through a param', () => {
|
|
const { container } = render(
|
|
<TranslationProvider>
|
|
<TransHtml html="journey.frontpage.suggestionText" params={{ title: '<script>alert(1)</script>' }} />
|
|
</TranslationProvider>,
|
|
);
|
|
|
|
expect(container.querySelector('script')).toBeNull();
|
|
expect(container.textContent).toContain('alert(1)');
|
|
});
|
|
});
|