mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
196 lines
8.0 KiB
TypeScript
196 lines
8.0 KiB
TypeScript
// FE-COMP-I18NCTX-001 to FE-COMP-I18NCTX-017
|
|
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-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-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-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)');
|
|
});
|
|
});
|