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:
2026-05-19 04:00:35 +02:00
parent e9c29dcf38
commit 878402e579
2 changed files with 143 additions and 5 deletions

View File

@@ -36,7 +36,7 @@
// Profile state loaded from public.users
let displayNameInput = $state('');
let language = $state<'en' | 'es'>('en');
let language = $state<'en' | 'es' | 'eu'>('en');
let avatarType = $state<'initials' | 'emoji' | 'upload'>('initials');
let avatarEmoji = $state<string | null>(null);
let avatarUrl = $state<string | null>(null);
@@ -119,7 +119,7 @@
if (data) {
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';
avatarEmoji = data.avatar_emoji;
avatarUrl = data.avatar_url;
@@ -246,7 +246,7 @@
}
}
async function switchLanguage(lang: 'en' | 'es') {
async function switchLanguage(lang: 'en' | 'es' | 'eu') {
language = lang;
setLanguageTag(lang);
await getSupabase()
@@ -438,9 +438,10 @@
{m.settings_language()}
</p>
<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
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
{language === code
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'

View 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();
}
});
});