diff --git a/docs/architecture.md b/docs/architecture.md index 82318e87c..0992fa7c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,6 +31,12 @@ Browser or Capacitor [`src/lib/supabase/AuthProvider.tsx`](../src/lib/supabase/AuthProvider.tsx) coordinates store hydration and reset across authentication changes. Store code is not the database schema source of truth; migrations are. +## Language behavior + +[`src/lib/languages.ts`](../src/lib/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and ``. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages. + +Interface language and AI-output language are separate settings. Interface language controls rendered copy, accessible names, generated app boilerplate, dates, numbers, relative time, PDF chrome, and Web Speech's recognition locale. AI-output language is sent through every structured, streaming, Crossfire, and Sparring request path and becomes an explicit backend prompt instruction. Existing user/provider content and previously saved titles are not rewritten when either setting changes. + ## Supabase environments and sessions [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. diff --git a/docs/testing.md b/docs/testing.md index a826a766e..5a3ab7df3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -87,6 +87,14 @@ Playwright starts a new Vite server on `127.0.0.1:18080` and runs: The server is never reused. CI uses retries and two workers. On failure, traces, screenshots, videos, and the HTML report stay in ignored `test-results/` and `playwright-report/` directories. CI may upload those credential-free artifacts for diagnosis. +`e2e/hungarian-parity.spec.ts` is the focused Hungarian browser contract. On desktop and mobile Chromium it checks persisted ``, public and authenticated page chrome, route titles, onboarding, saved-chat empty state, Library controls, Timer semantics, localized close labels, and runtime errors. It deliberately does not reject arbitrary English text because source titles, user content, model names, and citations may legitimately remain in their source language. + +`src/test/i18n.test.ts` requires identical key sets and non-empty values across all three catalogs. Its explicit identity allowlist is limited to values that should remain canonical, such as product names, file formats, prices, model-facing abbreviations, and debate-format codes. A matching key set and a passing allowlist test prove catalog structure, not Hungarian grammar or natural phrasing. + +The speech unit test proves that Hungarian selects `recognition.lang = 'hu-HU'`. Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support. + +AI client and Edge tests prove that every request path carries a validated language and that prompts require Hungarian output. Provider responses are mocked; only an authorized protected-staging run can prove that a live provider follows the instruction across every prose field. + ## Full credential-free gate ```bash diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index d7708fd49..cabf6a63e 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -128,7 +128,7 @@ test('header keeps the brand and controls inside a 320px viewport', async ({ pag const menuControl = page.getByRole('button', { name: 'Open menu' }); const controls = [languageControl, themeControl, settingsControl, menuControl]; - await expect(page.locator('header img[alt="DebateLab"]')).toBeVisible(); + await expect(page.getByAltText('DebateLab logo')).toBeVisible(); for (const control of controls) await expect(control).toBeVisible(); const boxes = await Promise.all(controls.map((control) => control.boundingBox())); diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts new file mode 100644 index 000000000..66597913d --- /dev/null +++ b/e2e/hungarian-parity.spec.ts @@ -0,0 +1,153 @@ +import { expect, test, type Page } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; + +function collectRuntimeErrors(page: Page) { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); + page.on('console', (message) => { + if (message.type() !== 'error') return; + const text = message.text(); + if (text.includes('Failed to load resource')) return; + if (text.includes('favicon')) return; + if (text.includes('net::ERR_')) return; + if (/^Failed to fetch(:|$)/.test(text)) return; + if (text.includes('Auth] hydrate')) return; + if (/^Warning:/.test(text)) return; + errors.push(`console: ${text}`); + }); + return errors; +} + +async function expectHungarianDocument(page: Page) { + await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); +} + +test.describe('Hungarian parity', () => { + test.beforeEach(async ({ page }) => { + await setLocale(page, 'hu'); + }); + + test('keeps public routes and landing navigation in Hungarian', async ({ page }) => { + const errors = collectRuntimeErrors(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + await expectHungarianDocument(page); + await expect(page.getByText('MI-alapú vitafelkészülés, ami előnyt ad minden fordulóban.')).toBeVisible(); + if ((page.viewportSize()?.width ?? 0) < 768) { + await expect(page.getByRole('button', { name: 'Menü megnyitása' })).toBeVisible(); + } else { + const navigation = page.getByRole('navigation', { name: 'Fő navigáció' }); + await expect(navigation.getByRole('link', { name: 'Funkciók' })).toBeVisible(); + await expect(navigation.getByRole('link', { name: 'Árak' })).toBeVisible(); + } + + await page.reload({ waitUntil: 'domcontentloaded' }); + await expectHungarianDocument(page); + + for (const [path, heading] of [ + ['/login', 'Bejelentkezés a DebateLab-ba'], + ['/terms', 'Felhasználási Feltételek'], + ['/privacy', 'Adatvédelmi Irányelvek'], + ['/expocit/sources', 'Források'], + ] as const) { + await page.goto(path, { waitUntil: 'domcontentloaded' }); + await expectHungarianDocument(page); + await expect(page.getByRole('heading', { name: heading, exact: true })).toBeVisible(); + } + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('renders Hungarian workspace navigation, route titles, and onboarding', async ({ page }, testInfo) => { + const errors = collectRuntimeErrors(page); + await stubSupabaseSession(page); + await page.addInitScript(() => { + try { + window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ + state: { hasCompletedOnboarding: false, isTourRunning: false }, + version: 0, + })); + } catch { + // Ignore transient opaque documents during browser navigation. + } + }); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + + await expectHungarianDocument(page); + await expect(page.getByRole('heading', { name: 'Felkészülés' })).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateLab-ban!', { timeout: 15_000 }); + await expect(page.locator('[data-test-id="button-primary"]')).toContainText('Következő'); + await expect(page.locator('[data-test-id="button-skip"]')).toContainText('Túra Kihagyása'); + await page.locator('[data-test-id="button-skip"]').click(); + + for (const [route, heading] of [ + ['generate', 'Generálás'], + ['attack', 'Támadás'], + ['brainstorm', 'Ötletelés'], + ['factoids', 'Tények'], + ['ask', 'Kérdezés'], + ] as const) { + const link = page.locator(`[data-tour="nav-${route}"]`).first(); + if (testInfo.project.name === 'mobile-chromium') { + await page.getByRole('button', { name: 'Menü megnyitása' }).click(); + await page.getByRole('navigation', { name: 'Mobil navigáció' }) + .getByRole('link') + .filter({ hasText: heading }) + .first() + .click(); + } else { + if (!await link.isVisible()) await page.locator('summary[data-tour="nav-more"]').click(); + await link.click(); + } + await expect(page).toHaveURL(new RegExp(`/app/${route}$`)); + await expect(page.getByRole('heading', { name: heading, exact: true }).last()).toBeVisible(); + } + + await page.reload({ waitUntil: 'domcontentloaded' }); + await expectHungarianDocument(page); + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('localizes saved chats, library controls, timer, and close labels', async ({ page }, testInfo) => { + const errors = collectRuntimeErrors(page); + await stubSupabaseSession(page); + await page.addInitScript(() => { + try { + window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ + state: { hasCompletedOnboarding: true, isTourRunning: false }, + version: 0, + })); + } catch { + // Ignore transient opaque documents during browser navigation. + } + }); + + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); + if (testInfo.project.name === 'mobile-chromium') { + await page.getByRole('button', { name: 'Mentett beszélgetések' }).click(); + const sheet = page.getByRole('dialog', { name: 'Mentett beszélgetések' }); + await expect(sheet.getByText('Még nincsenek mentett beszélgetések')).toBeVisible(); + await expect(sheet.getByRole('button', { name: 'Bezárás' })).toBeVisible(); + await sheet.getByRole('button', { name: 'Bezárás' }).click(); + } else { + await page.getByRole('button', { name: 'Mentett beszélgetések megnyitása' }).click(); + await expect(page.getByText('Még nincsenek mentett beszélgetések')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Bezárás' })).toBeVisible(); + } + + await page.goto('/app/library', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('textbox', { name: 'Források keresése' })).toBeVisible(); + await page.getByRole('button', { name: 'Forrás Hozzáadása' }).click(); + await expect(page.getByText('Importálás URL-ből')).toBeVisible(); + await page.getByRole('button', { name: 'Mentés a Könyvtárba' }).click(); + await expect(page.locator('[toast-close]')).toHaveAttribute('aria-label', 'Bezárás'); + + await page.goto('/app/flowchart', { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Betöltés' }).click(); + await expect(page.getByRole('dialog', { name: 'Mentett Folyamatábrák' }).getByRole('button', { name: 'Bezárás' })).toBeVisible(); + + await page.goto('/app/timer', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('timer', { name: 'Időmérő kijelző' })).toBeVisible(); + expect(errors, errors.join('\n')).toEqual([]); + }); +}); diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index c2099938d..465a53558 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -204,9 +204,9 @@ test('a failed route chunk recovers after the offered reload', async ({ page }) await expect(page.locator('header')).toBeVisible(); }); -for (const [locale, attackLabel, menuLabel, openingLabel] of [ - ['es', 'Atacar', 'Abrir menú', 'Abriendo Atacar'], - ['hu', 'Támadás', 'Menü megnyitása', 'Támadás megnyitása'], +for (const [locale, attackLabel, menuLabel, mainNavigationLabel, mobileNavigationLabel, openingLabel] of [ + ['es', 'Atacar', 'Abrir menú', 'Navegación principal', 'Navegación móvil', 'Abriendo Atacar'], + ['hu', 'Támadás', 'Menü megnyitása', 'Fő navigáció', 'Mobil navigáció', 'Támadás megnyitása'], ] as const) { test(`${locale} delayed route announces the localized destination`, async ({ page }) => { await setLocale(page, locale); @@ -223,9 +223,9 @@ for (const [locale, attackLabel, menuLabel, openingLabel] of [ if ((page.viewportSize()?.width ?? 1280) < 1024) { await page.getByRole('button', { name: menuLabel }).click(); - await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: new RegExp(`^${attackLabel}`) }).click(); + await page.getByRole('navigation', { name: mobileNavigationLabel }).getByRole('link', { name: new RegExp(`^${attackLabel}`) }).click(); } else { - await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: attackLabel }).click(); + await page.getByRole('navigation', { name: mainNavigationLabel }).getByRole('link', { name: attackLabel }).click(); } await expect.poll(() => destinationRequested).toBe(true); diff --git a/e2e/quality-backlog.spec.ts b/e2e/quality-backlog.spec.ts index ca9eb1d74..392fb0508 100644 --- a/e2e/quality-backlog.spec.ts +++ b/e2e/quality-backlog.spec.ts @@ -3,6 +3,11 @@ import { setLocale, stubSupabaseSession } from './helpers/auth'; const locales = ['en', 'es', 'hu'] as const; const titledRoutes = ['generate', 'attack', 'brainstorm', 'factoids', 'ask'] as const; +const mobileNavigationNames = { + en: 'Mobile navigation', + es: 'Navegación móvil', + hu: 'Mobil navigáció', +} as const; test('Sources does not show the stale consultation date', async ({ page }) => { await page.goto('/expocit/sources'); @@ -29,7 +34,7 @@ for (const locale of locales) { expect(label).toBeTruthy(); if (testInfo.project.name === 'mobile-chromium') { await page.locator('header button.lg\\:hidden[aria-expanded]').click(); - await page.getByRole('navigation', { name: 'Mobile navigation' }) + await page.getByRole('navigation', { name: mobileNavigationNames[locale] }) .getByRole('link') .filter({ hasText: label! }) .first() diff --git a/e2e/search-quality.spec.ts b/e2e/search-quality.spec.ts index ba833cf53..239950f39 100644 --- a/e2e/search-quality.spec.ts +++ b/e2e/search-quality.spec.ts @@ -83,8 +83,8 @@ test('authenticated Factoids shows the consulted-source quality panel', async ({ await expect(page.getByRole('heading', { name: /researched sources/i })).toBeVisible(); await expect(page.getByRole('link', { name: /official energy statistics/i }).first()).toHaveAttribute('href', strongSourceUrl); await expect(page.getByRole('link', { name: /broad commentary/i })).toHaveAttribute('href', weakSourceUrl); - await expect(page.getByText('strong', { exact: true })).toBeVisible(); - await expect(page.getByText('weak', { exact: true })).toBeVisible(); + await expect(page.getByText('Strong', { exact: true })).toBeVisible(); + await expect(page.getByText('Weak', { exact: true })).toBeVisible(); await expect(page.getByText('fallback context only', { exact: true })).toBeVisible(); await expect(page.getByText('Solar costs fell by 42 percent')).toBeVisible(); }); diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 2b20d6622..89755e86b 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -128,7 +128,7 @@ test('keeps the setup usable on mobile with reduced motion', async ({ page }) => await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('heading', { name: 'Sparring' })).toBeVisible(); - const logo = page.locator('header img[alt="DebateLab"]'); + const logo = page.getByAltText('DebateLab logo'); await expect(logo).toHaveAttribute('src', '/favicon.png'); await expect.poll(() => logo.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0); await expect(page.getByRole('combobox', { name: 'Round format' })).toHaveText('School 4-6-6-3 · 8 speeches'); diff --git a/src/App.tsx b/src/App.tsx index a4c1880c7..10237c792 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -121,7 +121,13 @@ export function getRouteFallback(pathname: string): RouteFallback { return ROUTE_FALLBACKS[normalizedPathname] ?? DEFAULT_ROUTE_FALLBACK; } -export class RouteErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { +interface RouteErrorBoundaryProps { + children: ReactNode; + heading?: string; + reloadLabel?: string; +} + +export class RouteErrorBoundary extends Component { state = { failed: false }; static getDerivedStateFromError() { @@ -137,9 +143,9 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { fai if (this.state.failed) { return (
-

Unable to load this page

+

{this.props.heading ?? 'Unable to load this page'}

); @@ -148,6 +154,15 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { fai } } +export function LocalizedRouteErrorBoundary({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + return ( + + {children} + + ); +} + export function ToolRouteAnalytics(): null { const location = useLocation(); @@ -167,11 +182,11 @@ function RouteContent({ children }: { children: ReactNode }) { const statusText = t("common.openingPage", { page: label }); return ( - + }> {children} - + ); } @@ -249,9 +264,9 @@ const App = () => ( - + - + diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 19b7bf99a..14cd245e6 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -5,12 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; - -const languages = [ - { value: 'en', label: 'English' }, - { value: 'es', label: 'Español' }, - { value: 'hu', label: 'Magyar' }, -] as const; +import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages'; export function LanguageSwitcher() { const { t } = useTranslation(); @@ -31,14 +26,14 @@ export function LanguageSwitcher() {
- isAppLanguage(value) && setLanguage(value)}> - {languages.map((lang) => ( - - {lang.label} + {Object.entries(APP_LANGUAGES).map(([value, language]) => ( + + {language.label} ))} @@ -46,14 +41,14 @@ export function LanguageSwitcher() {
- isAppLanguage(value) && setOutputLanguage(value)}> - {languages.map((lang) => ( - - {lang.label} + {Object.entries(APP_LANGUAGES).map(([value, language]) => ( + + {language.label} ))} diff --git a/src/components/admin/AdminAnnouncements.tsx b/src/components/admin/AdminAnnouncements.tsx index 9df4532a1..a53e6bde0 100644 --- a/src/components/admin/AdminAnnouncements.tsx +++ b/src/components/admin/AdminAnnouncements.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Ban, Loader2, Megaphone, Search, Trash2 } from 'lucide-react'; import { createAnnouncement, @@ -46,10 +47,10 @@ function isTargetType(value: string): value is AnnouncementTargetType { function getStatus(announcement: AdminAnnouncement) { const now = Date.now(); - if (announcement.deactivatedAt) return 'Deactivated'; - if (new Date(announcement.startsAt).getTime() > now) return 'Scheduled'; - if (announcement.expiresAt && new Date(announcement.expiresAt).getTime() <= now) return 'Expired'; - return 'Active'; + if (announcement.deactivatedAt) return 'deactivated'; + if (new Date(announcement.startsAt).getTime() > now) return 'scheduled'; + if (announcement.expiresAt && new Date(announcement.expiresAt).getTime() <= now) return 'expired'; + return 'active'; } export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { @@ -68,6 +69,7 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { const [startsAt, setStartsAt] = useState(''); const [expiresAt, setExpiresAt] = useState(''); const { toast } = useToast(); + const { t } = useTranslation(); useEffect(() => { const controller = new AbortController(); @@ -77,8 +79,8 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { } catch (error) { if (controller.signal.aborted) return; toast({ - title: 'Could not load announcements', - description: error instanceof Error ? error.message : 'Try again.', + title: t('admin.announcements.loadFailed'), + description: error instanceof Error ? error.message : t('admin.announcements.tryAgain'), variant: 'destructive', }); } finally { @@ -87,7 +89,7 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { }; void load(); return () => controller.abort(); - }, [toast]); + }, [t, toast]); const filteredUsers = useMemo(() => { const query = userSearch.trim().toLowerCase(); @@ -108,8 +110,8 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { if (!title.trim() || !body.trim()) return; if (targetType === 'users' && selectedUserIds.size === 0) { toast({ - title: 'Select at least one user', - description: 'Targeted announcements need a recipient.', + title: t('admin.announcements.selectUser'), + description: t('admin.announcements.recipientRequired'), variant: 'destructive', }); return; @@ -132,11 +134,11 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { setSelectedUserIds(new Set()); setStartsAt(''); setExpiresAt(''); - toast({ title: 'Announcement created', description: 'Users will see it when it becomes active.' }); + toast({ title: t('admin.announcements.created'), description: t('admin.announcements.createdDescription') }); } catch (error) { toast({ - title: 'Could not create announcement', - description: error instanceof Error ? error.message : 'Try again.', + title: t('admin.announcements.createFailed'), + description: error instanceof Error ? error.message : t('admin.announcements.tryAgain'), variant: 'destructive', }); } finally { @@ -151,11 +153,11 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { await deleteAnnouncement(deleteTarget.id); setAnnouncements((current) => current.filter((item) => item.id !== deleteTarget.id)); setDeleteTarget(null); - toast({ title: 'Announcement deleted' }); + toast({ title: t('admin.announcements.deleted') }); } catch (error) { toast({ - title: 'Could not delete announcement', - description: error instanceof Error ? error.message : 'Try again.', + title: t('admin.announcements.deleteFailed'), + description: error instanceof Error ? error.message : t('admin.announcements.tryAgain'), variant: 'destructive', }); } finally { @@ -172,11 +174,11 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { item.id === announcement.id ? announcement : item ))); setDeactivateTarget(null); - toast({ title: 'Announcement deactivated' }); + toast({ title: t('admin.announcements.deactivated') }); } catch (error) { toast({ - title: 'Could not deactivate announcement', - description: error instanceof Error ? error.message : 'Try again.', + title: t('admin.announcements.deactivateFailed'), + description: error instanceof Error ? error.message : t('admin.announcements.tryAgain'), variant: 'destructive', }); } finally { @@ -190,38 +192,38 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { - Send a dismissible message to everyone or selected users. + {t('admin.announcements.composeDescription')}
- + setTitle(event.target.value)} maxLength={160} - placeholder="Scheduled maintenance" + placeholder={t('admin.announcements.titlePlaceholder')} /> -

{title.length}/160 characters

+

{t('admin.announcements.characterCount', { count: title.length, max: 160 })}

- +