Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html lang>`. 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.
Expand Down
8 changes: 8 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html lang="hu">`, 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
Expand Down
2 changes: 1 addition & 1 deletion e2e/home-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
153 changes: 153 additions & 0 deletions e2e/hungarian-parity.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
10 changes: 5 additions & 5 deletions e2e/navigation-performance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion e2e/quality-backlog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions e2e/search-quality.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
2 changes: 1 addition & 1 deletion e2e/sparring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
29 changes: 22 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouteErrorBoundaryProps, { failed: boolean }> {
state = { failed: false };

static getDerivedStateFromError() {
Expand All @@ -137,9 +143,9 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { fai
if (this.state.failed) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-xl font-semibold">Unable to load this page</h1>
<h1 className="text-xl font-semibold">{this.props.heading ?? 'Unable to load this page'}</h1>
<button className="rounded-md bg-primary px-4 py-2 text-primary-foreground" onClick={() => window.location.reload()}>
Reload
{this.props.reloadLabel ?? 'Reload'}
</button>
</div>
);
Expand All @@ -148,6 +154,15 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { fai
}
}

export function LocalizedRouteErrorBoundary({ children }: { children: ReactNode }) {
const { t } = useTranslation();
return (
<RouteErrorBoundary heading={t('routeError.heading')} reloadLabel={t('routeError.reload')}>
{children}
</RouteErrorBoundary>
);
}

export function ToolRouteAnalytics(): null {
const location = useLocation();

Expand All @@ -167,11 +182,11 @@ function RouteContent({ children }: { children: ReactNode }) {
const statusText = t("common.openingPage", { page: label });

return (
<RouteErrorBoundary key={location.pathname}>
<LocalizedRouteErrorBoundary key={location.pathname}>
<Suspense fallback={<RouteSkeleton variant={fallback.variant} label={label} statusText={statusText} />}>
{children}
</Suspense>
</RouteErrorBoundary>
</LocalizedRouteErrorBoundary>
);
}

Expand Down Expand Up @@ -249,9 +264,9 @@ const App = () => (
<Sonner />
<OfflineIndicator />
<BrowserRouter useTransitions={false}>
<RouteErrorBoundary>
<LocalizedRouteErrorBoundary>
<AppRoutes />
</RouteErrorBoundary>
</LocalizedRouteErrorBoundary>
</BrowserRouter>
</TooltipProvider>
</MotionConfig>
Expand Down
23 changes: 9 additions & 14 deletions src/components/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -31,29 +26,29 @@ export function LanguageSwitcher() {
<div className="grid gap-5 py-2">
<div className="space-y-2">
<Label>{t('langSwitcher.uiLanguage', 'Interface language')}</Label>
<Select value={language} onValueChange={(v) => setLanguage(v as 'en' | 'es' | 'hu')}>
<Select value={language} onValueChange={(value) => isAppLanguage(value) && setLanguage(value)}>
<SelectTrigger aria-label={t('langSwitcher.uiLanguage', 'Interface language')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
{Object.entries(APP_LANGUAGES).map(([value, language]) => (
<SelectItem key={value} value={value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('langSwitcher.outputLanguage', 'AI output language')}</Label>
<Select value={outputLanguage} onValueChange={(v) => setOutputLanguage(v as 'en' | 'es' | 'hu')}>
<Select value={outputLanguage} onValueChange={(value) => isAppLanguage(value) && setOutputLanguage(value)}>
<SelectTrigger aria-label={t('langSwitcher.outputLanguage', 'AI output language')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
{Object.entries(APP_LANGUAGES).map(([value, language]) => (
<SelectItem key={value} value={value}>
{language.label}
</SelectItem>
))}
</SelectContent>
Expand Down
Loading
Loading