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
|
||||
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'
|
||||
|
||||
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