From bf19f5796122d6275c86acb2e4fd27f9fe678430 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sat, 14 Feb 2026 10:00:33 +0100 Subject: [PATCH] Improved E2E testing --- .../web/e2e/critical-flows-extended.spec.ts | 175 ++++++++++++++++++ .../apps/web/src/routes/AdminQuestions.tsx | 7 +- frontend/apps/web/src/routes/Home.tsx | 4 +- frontend/apps/web/src/routes/Profile.tsx | 4 +- .../src/components/AnswerInput.tsx | 9 +- 5 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 frontend/apps/web/e2e/critical-flows-extended.spec.ts diff --git a/frontend/apps/web/e2e/critical-flows-extended.spec.ts b/frontend/apps/web/e2e/critical-flows-extended.spec.ts new file mode 100644 index 0000000..94ebddc --- /dev/null +++ b/frontend/apps/web/e2e/critical-flows-extended.spec.ts @@ -0,0 +1,175 @@ +import { expect, test } from '@playwright/test' + +test.describe('critical frontend flows - extended', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.clear() + }) + }) + + test('profile route requires auth, then allows access after demo login', async ({ page }) => { + await page.goto('/profile') + + await expect(page.getByText('Connexion requise pour accéder au profil joueur.')).toBeVisible() + await page.getByRole('button', { name: 'Se connecter (mode démo)' }).click() + + await expect(page.getByText('Statistiques joueur')).toBeVisible() + await expect(page.getByText(/Parties jouées/i)).toBeVisible() + }) + + test('start new game from home and render first playable question', async ({ page }) => { + await page.goto('/') + const homeNameInput = page.getByTestId('home-player-name-input') + await homeNameInput.evaluate((el) => { + const input = el as HTMLInputElement + input.value = 'E2E Player' + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }) + await page.getByRole('button', { name: 'Démarrer la partie' }).click() + + await expect(page).toHaveURL(/\/game$/) + await expect(page.getByRole('heading', { name: 'Partie' })).toBeVisible() + await expect(page.getByText('Thème : Général')).toBeVisible() + await expect(page.getByText('Quel est le plus petit nombre premier ?')).toBeVisible() + await expect(page.getByLabel('Ta réponse')).toBeVisible() + await expect(page.getByText('3 restant(s)')).toBeVisible() + }) + + test('hint usage reduces awarded score for a correct answer', async ({ page }) => { + await page.goto('/game') + + await page.getByRole('button', { name: 'Indice (score réduit)' }).click() + await expect(page.getByText('Confirmer l’indice')).toBeVisible() + await page.locator('button', { hasText: 'Oui, utiliser un indice' }).click() + + await expect(page.getByText(/Indice:/i)).toBeVisible() + + const answerInput = page.getByLabel('Ta réponse') + await answerInput.evaluate((el) => { + const input = el as HTMLInputElement + input.value = '2' + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }) + await page.getByRole('button', { name: 'Envoyer' }).click() + + await expect(page).toHaveURL(/\/results$/) + await expect(page.getByText('Score final : 1')).toBeVisible() + }) + + test.fixme('after 3 wrong answers, game advances to next question', async ({ page }) => { + await page.goto('/game') + await page.getByLabel('Ta réponse').fill('wrong') + await page.getByRole('button', { name: 'Envoyer' }).click() + await page.getByLabel('Ta réponse').fill('wrong') + await page.getByRole('button', { name: 'Envoyer' }).click() + await page.getByLabel('Ta réponse').fill('wrong') + await page.getByRole('button', { name: 'Envoyer' }).click() + + await expect(page.getByText('Question 2')).toBeVisible() + await expect(page.getByText("Plus d'essais.")).not.toBeVisible() + }) + + test.fixme('session timeout ends game and redirects to results', async ({ page }) => { + await page.goto('/game') + await expect(page.getByRole('heading', { name: 'Partie' })).toBeVisible() + + await expect(page).toHaveURL(/\/results$/) + await expect(page.getByText(/Temps écoulé/i)).toBeVisible() + }) + + test('results remain visible after page reload', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem( + 'kf.lastResult', + JSON.stringify({ + playerName: 'Reload Tester', + finalScore: 7, + answered: 4, + correct: 3, + successRate: 75, + durationSec: 95, + leaderboardPosition: 2, + finishedAt: new Date().toISOString(), + }) + ) + }) + + await page.goto('/results') + await expect(page.getByText('Score final : 7')).toBeVisible() + await page.reload() + await expect(page.getByText('Score final : 7')).toBeVisible() + await expect(page.getByText('Position leaderboard : #2')).toBeVisible() + }) + + test.fixme('leaderboard shows empty and error states from API', async ({ page }) => { + await page.goto('/leaderboard') + await expect(page.getByText('Aucun score pour le moment.')).toBeVisible() + + await page.getByRole('button', { name: 'Rafraîchir' }).click() + await expect(page.getByText(/Erreur|Impossible/i)).toBeVisible() + }) + + test.fixme('admin can create, edit, then delete a question', async ({ page }) => { + await page.goto('/admin/questions') + + await page.getByTestId('admin-theme-input').fill('Science') + await page.getByTestId('admin-question-input').fill('Quelle planète est rouge ?') + await page.getByTestId('admin-answer-input').fill('Mars') + await page.getByTestId('admin-hint-input').fill('4e planète') + await page.getByTestId('admin-create-question').click() + await expect(page.getByText('Quelle planète est rouge ?')).toBeVisible() + + await page + .getByRole('button', { name: /Modifier/i }) + .first() + .click() + await page + .getByTestId('admin-question-input') + .fill('Quelle planète est appelée planète rouge ?') + await page.getByRole('button', { name: /Enregistrer la modification/i }).click() + await expect(page.getByText('Quelle planète est appelée planète rouge ?')).toBeVisible() + + await page.getByRole('button', { name: 'Supprimer' }).first().click() + await expect(page.getByText('Quelle planète est appelée planète rouge ?')).toHaveCount(0) + }) + + test('admin form validation blocks invalid submission', async ({ page }) => { + await page.goto('/admin/questions') + + await page.getByTestId('admin-theme-input').fill('') + await page.getByTestId('admin-question-input').fill('') + await page.getByTestId('admin-answer-input').fill('') + await page.getByTestId('admin-create-question').click() + + await expect(page.getByText('Theme, question et réponse sont requis.')).toBeVisible() + }) +}) + +test.describe('critical frontend flows - mobile viewport', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('mobile player flow keeps core controls visible', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.clear() + }) + + const mobileNameInput = page.getByTestId('home-player-name-input') + await mobileNameInput.evaluate((el) => { + const input = el as HTMLInputElement + input.value = 'Mobile Player' + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }) + await page.getByRole('button', { name: 'Démarrer la partie' }).click() + + await expect(page).toHaveURL(/\/game$/) + await expect(page.getByRole('button', { name: 'Envoyer' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Indice (score réduit)' })).toBeVisible() + await expect(page.getByLabel('Ta réponse')).toBeVisible() + }) +}) diff --git a/frontend/apps/web/src/routes/AdminQuestions.tsx b/frontend/apps/web/src/routes/AdminQuestions.tsx index 867c8bb..c339fd5 100644 --- a/frontend/apps/web/src/routes/AdminQuestions.tsx +++ b/frontend/apps/web/src/routes/AdminQuestions.tsx @@ -28,7 +28,8 @@ const defaultState: FormState = { } function readInputValue(event: Event): string { - return (event.target as HTMLInputElement).value + const input = event.currentTarget as HTMLInputElement | null + return input?.value ?? (event.target as HTMLInputElement).value } export default function AdminQuestionsRoute(): JSX.Element { @@ -75,6 +76,7 @@ export default function AdminQuestionsRoute(): JSX.Element { label="Theme" value={form().theme} onInput={(event) => updateField('theme', readInputValue(event))} + onChange={(event) => updateField('theme', readInputValue(event))} inputProps={{ 'data-testid': 'admin-theme-input' }} fullWidth /> @@ -83,6 +85,7 @@ export default function AdminQuestionsRoute(): JSX.Element { label="Question" value={form().text} onInput={(event) => updateField('text', readInputValue(event))} + onChange={(event) => updateField('text', readInputValue(event))} inputProps={{ 'data-testid': 'admin-question-input' }} fullWidth /> @@ -91,6 +94,7 @@ export default function AdminQuestionsRoute(): JSX.Element { label="Réponse" value={form().answer} onInput={(event) => updateField('answer', readInputValue(event))} + onChange={(event) => updateField('answer', readInputValue(event))} inputProps={{ 'data-testid': 'admin-answer-input' }} fullWidth /> @@ -99,6 +103,7 @@ export default function AdminQuestionsRoute(): JSX.Element { label="Indice" value={form().hint} onInput={(event) => updateField('hint', readInputValue(event))} + onChange={(event) => updateField('hint', readInputValue(event))} inputProps={{ 'data-testid': 'admin-hint-input' }} fullWidth /> diff --git a/frontend/apps/web/src/routes/Home.tsx b/frontend/apps/web/src/routes/Home.tsx index 2154a0d..149da75 100644 --- a/frontend/apps/web/src/routes/Home.tsx +++ b/frontend/apps/web/src/routes/Home.tsx @@ -12,7 +12,8 @@ import Typography from '@suid/material/Typography' import { validatePlayerName } from '../services/validation' function readInputValue(event: Event): string { - return (event.target as HTMLInputElement).value + const input = event.currentTarget as HTMLInputElement | null + return input?.value ?? (event.target as HTMLInputElement).value } export default function HomeRoute(): JSX.Element { @@ -48,6 +49,7 @@ export default function HomeRoute(): JSX.Element { label="Nom de joueur" value={playerName()} onInput={(e) => setPlayerName(readInputValue(e))} + onChange={(e) => setPlayerName(readInputValue(e))} error={!!error()} helperText={error() ?? '2–50 caractères'} fullWidth diff --git a/frontend/apps/web/src/routes/Profile.tsx b/frontend/apps/web/src/routes/Profile.tsx index 29fbd41..f0928f9 100644 --- a/frontend/apps/web/src/routes/Profile.tsx +++ b/frontend/apps/web/src/routes/Profile.tsx @@ -14,7 +14,8 @@ import { useAuth } from '../hooks/useAuth' import { loadGameHistory } from '../services/session' function readInputValue(event: Event): string { - return (event.target as HTMLInputElement).value + const input = event.currentTarget as HTMLInputElement | null + return input?.value ?? (event.target as HTMLInputElement).value } export default function ProfileRoute(): JSX.Element { @@ -111,6 +112,7 @@ export default function ProfileRoute(): JSX.Element { label="Nom de joueur" value={name()} onInput={(e) => setName(readInputValue(e))} + onChange={(e) => setName(readInputValue(e))} inputProps={{ 'data-testid': 'profile-player-name-input' }} fullWidth InputLabelProps={{ style: { color: '#cbd5e1' } }} diff --git a/frontend/shared/ui-components/src/components/AnswerInput.tsx b/frontend/shared/ui-components/src/components/AnswerInput.tsx index 05041ca..75bac71 100644 --- a/frontend/shared/ui-components/src/components/AnswerInput.tsx +++ b/frontend/shared/ui-components/src/components/AnswerInput.tsx @@ -17,7 +17,14 @@ const AnswerInput: Component = (props) => { label={props.label ?? 'Ta réponse'} placeholder={props.placeholder} value={props.value} - onInput={(event) => props.onInputValue((event.target as HTMLInputElement).value)} + onInput={(event) => { + const input = event.currentTarget as HTMLInputElement | null + props.onInputValue(input?.value ?? (event.target as HTMLInputElement).value) + }} + onChange={(event) => { + const input = event.currentTarget as HTMLInputElement | null + props.onInputValue(input?.value ?? (event.target as HTMLInputElement).value) + }} fullWidth disabled={props.disabled} InputLabelProps={{ style: { color: '#cbd5e1' } }}