mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
d07b8ba56e
- Web search: ChatGPT (Responses web_search), Anthropic (web_search server
tool) and OpenRouter (web plugin) search the web themselves when the admin
switch allows it (on as it ships). A search shows as a step ("Searching the
web for ..."), the cited pages as source chips under the answer. Anthropic
answers that searched are replayed with their blocks (new provider state
adapter); the search step never goes back to the model as a call. The
prompt says when to search and that chosen places still go through
search_place.
- Tour writes: assign_tour_track (undo: take that assignment off),
unassign_tour_track, set_tour_activity_excluded (undo: the other switch),
link_tour_packing and unlink_tour_packing (undo from the link before, which
both answers now name) get change cards and their ways back.
236 lines
9.9 KiB
TypeScript
236 lines
9.9 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>;
|
|
}
|
|
|
|
// A language's string table is built from its files the first time it is asked
|
|
// for; on a busy runner that can take longer than waitFor's default second.
|
|
const FIRST_LOAD = { timeout: 5000 };
|
|
|
|
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'), FIRST_LOAD);
|
|
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'), FIRST_LOAD);
|
|
});
|
|
|
|
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'), FIRST_LOAD);
|
|
});
|
|
|
|
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 () => {
|
|
// The chunk is loaded here first: FE-COMP-I18NCTX-011a starts every locale at
|
|
// once without waiting, and the Russian one could otherwise queue behind the
|
|
// others past any timeout. The provider still switches asynchronously, so the
|
|
// first render below is English all the same.
|
|
await import('@trek/shared/i18n/ru');
|
|
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 место'), FIRST_LOAD);
|
|
});
|
|
|
|
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)');
|
|
});
|
|
});
|