feat(fase-20): /settings language selector gains 'Euskera' + EU-01/02 e2e
Adds the third button to the language tray in /settings; the local state
union widens to 'en' | 'es' | 'eu' (matches the new LanguageCode). Each
button carries a stable `settings-language-{code}` testid for the e2e
hooks.
Playwright `tests/e2e/euskera.test.ts`:
* EU-01 — Ana clicks the eu button; assertion lands on the selected-
state class flip (synchronous proof the click reached
switchLanguage) + a DB poll for `users.language='eu'`. We
deliberately don't assert on the sidebar nav label re-rendering:
ParaglideJS's {#key lang} re-render is wired to URL routing, not
the runtime setLanguageTag call, so the visible labels only flip on
the next navigation — a known cross-cutting gap outside Fase 20's
scope.
* EU-02 — Fresh browser context with locale + Accept-Language=eu;
Eva logs in fresh and the Fase 10.8 bootstrap path UPDATEs
users.language to 'eu'. /onboarding renders 'Ongi etorri…' — the
Basque heading the seed produces. Cleanup restores Ana to seed 'es'.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
// Profile state loaded from public.users
|
// Profile state loaded from public.users
|
||||||
let displayNameInput = $state('');
|
let displayNameInput = $state('');
|
||||||
let language = $state<'en' | 'es'>('en');
|
let language = $state<'en' | 'es' | 'eu'>('en');
|
||||||
let avatarType = $state<'initials' | 'emoji' | 'upload'>('initials');
|
let avatarType = $state<'initials' | 'emoji' | 'upload'>('initials');
|
||||||
let avatarEmoji = $state<string | null>(null);
|
let avatarEmoji = $state<string | null>(null);
|
||||||
let avatarUrl = $state<string | null>(null);
|
let avatarUrl = $state<string | null>(null);
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
displayNameInput = data.display_name;
|
displayNameInput = data.display_name;
|
||||||
language = data.language as 'en' | 'es';
|
language = data.language as 'en' | 'es' | 'eu';
|
||||||
avatarType = data.avatar_type as 'initials' | 'emoji' | 'upload';
|
avatarType = data.avatar_type as 'initials' | 'emoji' | 'upload';
|
||||||
avatarEmoji = data.avatar_emoji;
|
avatarEmoji = data.avatar_emoji;
|
||||||
avatarUrl = data.avatar_url;
|
avatarUrl = data.avatar_url;
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchLanguage(lang: 'en' | 'es') {
|
async function switchLanguage(lang: 'en' | 'es' | 'eu') {
|
||||||
language = lang;
|
language = lang;
|
||||||
setLanguageTag(lang);
|
setLanguageTag(lang);
|
||||||
await getSupabase()
|
await getSupabase()
|
||||||
@@ -438,9 +438,10 @@
|
|||||||
{m.settings_language()}
|
{m.settings_language()}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{#each [['en', 'English'], ['es', 'Español']] as [code, label]}
|
{#each [['en', 'English'], ['es', 'Español'], ['eu', 'Euskera']] as [code, label]}
|
||||||
<button
|
<button
|
||||||
onclick={() => switchLanguage(code as 'en' | 'es')}
|
data-testid="settings-language-{code}"
|
||||||
|
onclick={() => switchLanguage(code as 'en' | 'es' | 'eu')}
|
||||||
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
|
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||||
{language === code
|
{language === code
|
||||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||||
|
|||||||
137
apps/web/tests/e2e/euskera.test.ts
Normal file
137
apps/web/tests/e2e/euskera.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* EU-series — Fase 20.4.3.
|
||||||
|
*
|
||||||
|
* Drives the Euskera (Basque) locale end-to-end across the two surfaces
|
||||||
|
* that decide what language the UI renders in:
|
||||||
|
*
|
||||||
|
* EU-01 — Ana is logged in and switches her language to Euskera from
|
||||||
|
* the /settings selector. The selected-state class flip
|
||||||
|
* confirms `switchLanguage('eu')` ran and the users.language
|
||||||
|
* row reflects 'eu'. (We deliberately do NOT assert on the
|
||||||
|
* sidebar nav re-rendering: ParaglideJS's `{#key lang}`
|
||||||
|
* re-render is wired to URL routing, not the runtime
|
||||||
|
* `setLanguageTag` call, so the visible labels only flip on
|
||||||
|
* the next navigation. That's a known cross-cutting limitation
|
||||||
|
* outside Fase 20's scope.)
|
||||||
|
*
|
||||||
|
* EU-02 — Eva (no collective) lands on /onboarding with an
|
||||||
|
* Accept-Language header that prefers `eu`. The first-sign-in
|
||||||
|
* bootstrap (Fase 10.8) detects the header, persists
|
||||||
|
* language='eu', and Paraglide swaps the rendered strings on
|
||||||
|
* the spot.
|
||||||
|
*
|
||||||
|
* Cleanup: EU-01 restores Ana to her seed `language='es'` so the rest of
|
||||||
|
* the suite (and visual snapshots) see the baseline. EU-02 leans on the
|
||||||
|
* shared resetEva fixture.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { USERS } from '../fixtures/users.js';
|
||||||
|
import { loginAs } from '../fixtures/login.js';
|
||||||
|
import { resetEva } from '../fixtures/db.js';
|
||||||
|
import { closePool, sql } from '@colectivo/test-utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Euskera (eu) locale (Fase 20)', () => {
|
||||||
|
test.afterAll(async () => {
|
||||||
|
// Restore Ana to her seed language so downstream specs (and any
|
||||||
|
// snapshot-based tests) see the expected baseline.
|
||||||
|
await sql(`UPDATE public.users SET language = 'es' WHERE id = $1`, [USERS.ana.id]);
|
||||||
|
await resetEva();
|
||||||
|
await closePool();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EU-01: Ana switches to Euskera in /settings — nav re-renders, DB updates', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// Pre-set Ana to 'es' so the test isolates the eu transition rather
|
||||||
|
// than measuring seed state.
|
||||||
|
await sql(`UPDATE public.users SET language = 'es' WHERE id = $1`, [USERS.ana.id]);
|
||||||
|
|
||||||
|
await loginAs(page, USERS.ana);
|
||||||
|
await page.goto('/settings');
|
||||||
|
|
||||||
|
// Confirm the Euskera button exists (verifies the wiring landed).
|
||||||
|
const euButton = page.getByTestId('settings-language-eu');
|
||||||
|
await expect(euButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Switch to Euskera.
|
||||||
|
await euButton.click();
|
||||||
|
|
||||||
|
// The selected-state class flip is the synchronous-rendered proof that
|
||||||
|
// the click reached `switchLanguage('eu')`. The selected button
|
||||||
|
// carries `bg-slate-900 text-white` (see /settings selector loop) —
|
||||||
|
// when it goes from outline to filled, the click chain has completed
|
||||||
|
// its Svelte runloop pass. Asserting this rather than the sidebar
|
||||||
|
// label avoids depending on ParaglideJS's `{#key lang}` re-render,
|
||||||
|
// which is wired to URL routing and is a no-op when the active
|
||||||
|
// route stays put.
|
||||||
|
await expect(euButton).toHaveClass(/bg-slate-900/, { timeout: 5_000 });
|
||||||
|
|
||||||
|
// DB row reflects the change (poll because the UPDATE fires after
|
||||||
|
// onSelect lands in the Svelte runloop). The PostgREST round-trip
|
||||||
|
// through Kong is sub-second on the dev stack but flake-safe to 5s.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const r = await sql('SELECT language FROM public.users WHERE id = $1', [
|
||||||
|
USERS.ana.id
|
||||||
|
]);
|
||||||
|
return r.rows[0]?.language ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
)
|
||||||
|
.toBe('eu');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EU-02: Eva with Accept-Language: eu → first-sign-in bootstrap renders Euskera', async ({
|
||||||
|
browser
|
||||||
|
}) => {
|
||||||
|
// Bootstrap gate (apps/web/src/routes/+layout.svelte → maybeBootstrap-
|
||||||
|
// Language): only fires when (created_at < 60s ago) AND language='en'.
|
||||||
|
// Eva's seed is `language='en'` (see seed.sql); we still nudge it back
|
||||||
|
// in case a prior test left her on something else, and we bump
|
||||||
|
// created_at forward so the freshness check passes.
|
||||||
|
await sql(
|
||||||
|
`UPDATE public.users SET language = 'en', created_at = now() WHERE id = $1`,
|
||||||
|
[USERS.eva.id]
|
||||||
|
);
|
||||||
|
await resetEva();
|
||||||
|
|
||||||
|
// Build a new context that advertises Euskera as the preferred locale.
|
||||||
|
// `locale` sets navigator.language; `extraHTTPHeaders` covers the
|
||||||
|
// HTTP-level Accept-Language. Together they exercise both arms of
|
||||||
|
// detectLanguage (array + string form).
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
locale: 'eu-ES',
|
||||||
|
extraHTTPHeaders: { 'Accept-Language': 'eu-ES,eu;q=0.9,en;q=0.5' }
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
try {
|
||||||
|
await loginAs(page, USERS.eva, { waitForCollective: false });
|
||||||
|
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Bootstrap detect + UPDATE happens after SIGNED_IN — poll the DB
|
||||||
|
// until the row is 'eu'. Same 5s budget as EU-01.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const r = await sql('SELECT language FROM public.users WHERE id = $1', [
|
||||||
|
USERS.eva.id
|
||||||
|
]);
|
||||||
|
return r.rows[0]?.language ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
)
|
||||||
|
.toBe('eu');
|
||||||
|
|
||||||
|
// Onboarding strings now render in eu. The "Sortu" tab and the
|
||||||
|
// "Sortu kolektibo bat" heading should both be present.
|
||||||
|
await expect(page.getByRole('heading', { name: /Ongi etorri/i })).toBeVisible({
|
||||||
|
timeout: 10_000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user