Added E2E testing scenarios

master
oabrivard 4 months ago
parent bf19f57961
commit df20d76595

@ -1,4 +1,13 @@
import { expect, test } from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
async function setInputValue(selector: string, value: string, page: Page): Promise<void> {
await page.locator(selector).evaluate((el, next) => {
const input = el as HTMLInputElement
input.value = next
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
}, value)
}
test.describe('critical frontend flows - extended', () => {
test.beforeEach(async ({ page }) => {
@ -20,13 +29,7 @@ test.describe('critical frontend flows - extended', () => {
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 setInputValue('[data-testid="home-player-name-input"]', 'E2E Player', page)
await page.getByRole('button', { name: 'Démarrer la partie' }).click()
await expect(page).toHaveURL(/\/game$/)
@ -46,38 +49,44 @@ test.describe('critical frontend flows - extended', () => {
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 setInputValue('[data-testid="game-answer-input"]', '2', page)
await page.getByRole('button', { name: 'Envoyer' }).click()
await expect(page).toHaveURL(/\/game$/)
await expect(page.getByText('Score: 1')).toBeVisible()
await expect(page.getByText('Quelle planète est surnommée la planète rouge ?')).toBeVisible()
await setInputValue('[data-testid="game-answer-input"]', 'mars', page)
await page.getByRole('button', { name: 'Envoyer' }).click()
await expect(page).toHaveURL(/\/results$/)
await expect(page.getByText('Score final : 1')).toBeVisible()
await expect(page.getByText('Score final : 3')).toBeVisible()
})
test.fixme('after 3 wrong answers, game advances to next question', async ({ page }) => {
test('after 3 wrong answers, game advances to next question', async ({ page }) => {
await page.goto('/game')
await page.getByLabel('Ta réponse').fill('wrong')
await setInputValue('[data-testid="game-answer-input"]', 'wrong', page)
await page.getByRole('button', { name: 'Envoyer' }).click()
await page.getByLabel('Ta réponse').fill('wrong')
await setInputValue('[data-testid="game-answer-input"]', 'wrong', page)
await page.getByRole('button', { name: 'Envoyer' }).click()
await page.getByLabel('Ta réponse').fill('wrong')
await setInputValue('[data-testid="game-answer-input"]', 'wrong', page)
await page.getByRole('button', { name: 'Envoyer' }).click()
await expect(page.getByText('Question 2')).toBeVisible()
await expect(page).toHaveURL(/\/game$/)
await expect(page.getByText('Quelle planète est surnommée la planète rouge ?')).toBeVisible()
await expect(page.getByText("Plus d'essais.")).not.toBeVisible()
})
test.fixme('session timeout ends game and redirects to results', async ({ page }) => {
test('session timeout ends game and redirects to results', async ({ page }) => {
await page.goto('/')
await page.evaluate(() => {
localStorage.setItem('kf.e2e.timerMs', '300')
})
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()
await expect(page.getByText('Score final : 0')).toBeVisible()
})
test('results remain visible after page reload', async ({ page }) => {
@ -105,21 +114,42 @@ test.describe('critical frontend flows - extended', () => {
await expect(page.getByText('Position leaderboard : #2')).toBeVisible()
})
test.fixme('leaderboard shows empty and error states from API', async ({ page }) => {
test('leaderboard shows empty and error states from API', async ({ page }) => {
await page.goto('/')
await page.evaluate(() => {
localStorage.setItem('kf.leaderboard.mode', 'api')
})
await page.route('**/leaderboard/top10', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [] }),
})
})
await page.goto('/leaderboard')
await expect(page.getByText('Aucun score pour le moment.')).toBeVisible()
await page.unroute('**/leaderboard/top10')
await page.route('**/leaderboard/top10', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'boom' }),
})
})
await page.getByRole('button', { name: 'Rafraîchir' }).click()
await expect(page.getByText(/Erreur|Impossible/i)).toBeVisible()
await expect(
page.getByText('Impossible de charger le leaderboard. Veuillez réessayer.')
).toBeVisible()
})
test.fixme('admin can create, edit, then delete a question', async ({ page }) => {
test('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 setInputValue('[data-testid="admin-theme-input"]', 'Science', page)
await setInputValue('[data-testid="admin-question-input"]', 'Quelle planète est rouge ?', page)
await setInputValue('[data-testid="admin-answer-input"]', 'Mars', page)
await setInputValue('[data-testid="admin-hint-input"]', '4e planète', page)
await page.getByTestId('admin-create-question').click()
await expect(page.getByText('Quelle planète est rouge ?')).toBeVisible()
@ -127,9 +157,11 @@ test.describe('critical frontend flows - extended', () => {
.getByRole('button', { name: /Modifier/i })
.first()
.click()
await page
.getByTestId('admin-question-input')
.fill('Quelle planète est appelée planète rouge ?')
await setInputValue(
'[data-testid="admin-question-input"]',
'Quelle planète est appelée planète rouge ?',
page
)
await page.getByRole('button', { name: /Enregistrer la modification/i }).click()
await expect(page.getByText('Quelle planète est appelée planète rouge ?')).toBeVisible()
@ -158,13 +190,7 @@ test.describe('critical frontend flows - mobile viewport', () => {
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 setInputValue('[data-testid="home-player-name-input"]', 'Mobile Player', page)
await page.getByRole('button', { name: 'Démarrer la partie' }).click()
await expect(page).toHaveURL(/\/game$/)

@ -1 +1 @@
{"version":"1.6.1","results":[[":src/hooks/useAuth.test.ts",{"duration":2,"failed":false}],[":src/services/session.test.ts",{"duration":4,"failed":false}],[":src/services/adminQuestions.test.ts",{"duration":1,"failed":false}],[":src/hooks/useTimer.test.ts",{"duration":3,"failed":false}],[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/services/api.test.ts",{"duration":2,"failed":false}],[":src/routes/Home.test.tsx",{"duration":96,"failed":false}],[":src/routes/Profile.test.tsx",{"duration":111,"failed":false}],[":src/components/AppShell.test.tsx",{"duration":42,"failed":false}],[":src/routes/AdminQuestions.test.tsx",{"duration":113,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":89,"failed":false}],[":src/routes/Results.test.tsx",{"duration":31,"failed":false}],[":src/routes/Game.test.tsx",{"duration":98,"failed":false}]]}
{"version":"1.6.1","results":[[":src/services/adminQuestions.test.ts",{"duration":2,"failed":false}],[":src/hooks/useTimer.test.ts",{"duration":3,"failed":false}],[":src/services/api.test.ts",{"duration":5,"failed":false}],[":src/services/session.test.ts",{"duration":3,"failed":false}],[":src/routes/Home.test.tsx",{"duration":104,"failed":false}],[":src/components/AppShell.test.tsx",{"duration":42,"failed":false}],[":src/hooks/useAuth.test.ts",{"duration":2,"failed":false}],[":src/routes/Results.test.tsx",{"duration":30,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":86,"failed":false}],[":src/routes/Game.test.tsx",{"duration":147,"failed":false}],[":src/routes/Profile.test.tsx",{"duration":115,"failed":false}],[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/routes/AdminQuestions.test.tsx",{"duration":108,"failed":false}]]}

@ -13,6 +13,7 @@ import {
createAdminQuestion,
deleteAdminQuestion,
listAdminQuestions,
updateAdminQuestion,
type AdminQuestion,
type CreateQuestionInput,
} from '../services/adminQuestions'
@ -36,6 +37,7 @@ export default function AdminQuestionsRoute(): JSX.Element {
const [items, setItems] = createSignal<AdminQuestion[]>(listAdminQuestions())
const [form, setForm] = createSignal<FormState>(defaultState)
const [error, setError] = createSignal<string | null>(null)
const [editingId, setEditingId] = createSignal<string | null>(null)
const updateField = (key: keyof FormState, value: string): void => {
setForm((prev) => ({ ...prev, [key]: value }) as FormState)
@ -48,15 +50,47 @@ export default function AdminQuestionsRoute(): JSX.Element {
return
}
createAdminQuestion(current)
if (editingId()) {
const updated = updateAdminQuestion(editingId() as string, current)
if (!updated) {
setError('Impossible de modifier la question.')
return
}
} else {
createAdminQuestion(current)
}
setItems(listAdminQuestions())
setForm(defaultState)
setError(null)
setEditingId(null)
}
const remove = (id: string): void => {
deleteAdminQuestion(id)
setItems(listAdminQuestions())
if (editingId() === id) {
setEditingId(null)
setForm(defaultState)
setError(null)
}
}
const edit = (item: AdminQuestion): void => {
setEditingId(item.id)
setForm({
theme: item.theme,
text: item.text,
answer: item.answer,
hint: item.hint,
difficulty: item.difficulty,
})
setError(null)
}
const cancelEdit = (): void => {
setEditingId(null)
setForm(defaultState)
setError(null)
}
return (
@ -70,6 +104,9 @@ export default function AdminQuestionsRoute(): JSX.Element {
<CardContent>
<Stack spacing={2}>
<Typography variant="h6">Créer une question</Typography>
{editingId() && (
<Typography sx={{ opacity: 0.8 }}>Mode édition: {editingId()}</Typography>
)}
{error() && <Typography color="error">{error()}</Typography>}
<TextField
@ -121,9 +158,16 @@ export default function AdminQuestionsRoute(): JSX.Element {
<MenuItem value="hard">hard</MenuItem>
</TextField>
<Button variant="contained" onClick={submit} data-testid="admin-create-question">
Ajouter la question
</Button>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
<Button variant="contained" onClick={submit} data-testid="admin-create-question">
{editingId() ? 'Enregistrer la modification' : 'Ajouter la question'}
</Button>
{editingId() && (
<Button variant="outlined" onClick={cancelEdit}>
Annuler
</Button>
)}
</Stack>
</Stack>
</CardContent>
</Card>
@ -138,6 +182,13 @@ export default function AdminQuestionsRoute(): JSX.Element {
<Typography sx={{ fontWeight: 700 }}>{item.text}</Typography>
<Typography sx={{ opacity: 0.8 }}>Theme: {item.theme}</Typography>
<Typography sx={{ opacity: 0.8 }}>Réponse: {item.answer}</Typography>
<Button
variant="outlined"
onClick={() => edit(item)}
data-testid={`admin-edit-${item.id}`}
>
Modifier
</Button>
<Button
variant="outlined"
color="error"

@ -1,13 +1,24 @@
import { render, screen } from '@solidjs/testing-library'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import GameRoute from './Game'
const navigateMock = vi.fn()
vi.mock('@solidjs/router', () => ({
useNavigate: (): ((path: string) => void) => vi.fn(),
useNavigate: (): ((path: string) => void) => navigateMock,
}))
describe('GameRoute', () => {
beforeEach(() => {
navigateMock.mockReset()
localStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
it('renders shared game UI components', () => {
render(() => <GameRoute />)
@ -16,4 +27,36 @@ describe('GameRoute', () => {
expect(screen.getByText('Essais')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Indice (score réduit)' })).toBeTruthy()
})
it('advances to next question after three wrong attempts', async () => {
render(() => <GameRoute />)
const answerInput = screen.getByLabelText('Ta réponse')
const submit = screen.getByRole('button', { name: 'Envoyer' })
fireEvent.input(answerInput, { target: { value: 'wrong' } })
fireEvent.click(submit)
fireEvent.input(answerInput, { target: { value: 'wrong' } })
fireEvent.click(submit)
fireEvent.input(answerInput, { target: { value: 'wrong' } })
fireEvent.click(submit)
await waitFor(() => {
expect(screen.getByText('Quelle planète est surnommée la planète rouge ?')).toBeTruthy()
})
expect(navigateMock).not.toHaveBeenCalled()
})
it('finalizes to results when timer expires', async () => {
vi.useFakeTimers()
localStorage.setItem('kf.e2e.timerMs', '300')
render(() => <GameRoute />)
await vi.advanceTimersByTimeAsync(1_000)
await vi.runAllTimersAsync()
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith('/results')
})
})
})

@ -24,11 +24,41 @@ type QuizQuestion = {
hint: string
}
const QUESTION: QuizQuestion = {
theme: 'Général',
text: 'Quel est le plus petit nombre premier ?',
answer: '2',
hint: 'Cest le seul nombre premier pair.',
const QUESTIONS: QuizQuestion[] = [
{
theme: 'Général',
text: 'Quel est le plus petit nombre premier ?',
answer: '2',
hint: 'Cest le seul nombre premier pair.',
},
{
theme: 'Astronomie',
text: 'Quelle planète est surnommée la planète rouge ?',
answer: 'mars',
hint: 'Cest la 4e planète du système solaire.',
},
]
const DEFAULT_DURATION_MS = 30 * 60 * 1000
const E2E_TIMER_MS_STORAGE_KEY = 'kf.e2e.timerMs'
function readDurationMsFromStorage(): number | null {
if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') return null
const raw = window.localStorage.getItem(E2E_TIMER_MS_STORAGE_KEY)
if (!raw) return null
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed <= 0) return null
return parsed
}
function resolveDurationMs(): number {
const fromStorage = readDurationMsFromStorage()
if (fromStorage != null) return fromStorage
const rawEnv = import.meta.env.VITE_E2E_TIMER_MS as string | undefined
if (!rawEnv) return DEFAULT_DURATION_MS
const parsed = Number.parseInt(rawEnv, 10)
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_DURATION_MS
return parsed
}
function normalizeAnswer(answer: string): string {
@ -66,15 +96,19 @@ async function estimateLeaderboardPosition(
export default function GameRoute(): JSX.Element {
const navigate = useNavigate()
const [question] = createSignal(QUESTION)
const [questionIndex, setQuestionIndex] = createSignal(0)
const [answer, setAnswer] = createSignal('')
const [attempts, setAttempts] = createSignal(0)
const [hintUsed, setHintUsed] = createSignal(false)
const [score, setScore] = createSignal(0)
const [answeredCount, setAnsweredCount] = createSignal(0)
const [correctCount, setCorrectCount] = createSignal(0)
const [message, setMessage] = createSignal<string | null>(null)
const [finished, setFinished] = createSignal(false)
const durationMs = 30 * 60 * 1000
const question = createMemo((): QuizQuestion => QUESTIONS[questionIndex()] ?? QUESTIONS[0])
const hasNextQuestion = createMemo((): boolean => questionIndex() < QUESTIONS.length - 1)
const durationMs = resolveDurationMs()
const startedAt = Date.now()
const { remainingMs, isExpired, start, stop } = useTimer(durationMs)
@ -110,11 +144,20 @@ export default function GameRoute(): JSX.Element {
navigate('/results')
}
const moveToNextQuestion = (nextMessage: string): void => {
if (!hasNextQuestion()) return
setQuestionIndex((idx) => idx + 1)
setAttempts(0)
setHintUsed(false)
setAnswer('')
setMessage(nextMessage)
}
createEffect((): void => {
if (isExpired() && !finished()) {
setMessage('Temps écoulé.')
const answered = attempts() > 0 ? 1 : 0
void finalize(score(), answered, 0)
const currentQuestionAnswered = attempts() > 0 ? 1 : 0
void finalize(score(), answeredCount() + currentQuestionAnswered, correctCount())
}
})
@ -135,19 +178,32 @@ export default function GameRoute(): JSX.Element {
const delta = hintUsed() ? 1 : 2
const nextScore = score() + delta
setScore(nextScore)
setMessage(`Bonne réponse (+${delta}).`)
void finalize(nextScore, 1, 1)
const nextAnswered = answeredCount() + 1
const nextCorrect = correctCount() + 1
setAnsweredCount(nextAnswered)
setCorrectCount(nextCorrect)
if (hasNextQuestion()) {
moveToNextQuestion(`Bonne réponse (+${delta}). Question suivante.`)
} else {
setMessage(`Bonne réponse (+${delta}).`)
void finalize(nextScore, nextAnswered, nextCorrect)
}
return
}
if (nextAttempts >= 3) {
setMessage("Mauvaise réponse. Plus d'essais.")
void finalize(score(), 1, 0)
const nextAnswered = answeredCount() + 1
setAnsweredCount(nextAnswered)
if (hasNextQuestion()) {
moveToNextQuestion('Mauvaise réponse. Passage à la question suivante.')
} else {
setMessage("Mauvaise réponse. Plus d'essais.")
void finalize(score(), nextAnswered, correctCount())
}
} else {
setMessage('Mauvaise réponse.')
setAnswer('')
}
setAnswer('')
}
const confirmHint = (): void => {
@ -169,6 +225,7 @@ export default function GameRoute(): JSX.Element {
label="Ta réponse"
value={answer()}
disabled={answerLocked()}
testId="game-answer-input"
onInputValue={setAnswer}
onSubmit={submit}
/>

@ -1,10 +1,26 @@
import { render, screen, waitFor } from '@solidjs/testing-library'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LeaderboardRoute from './Leaderboard'
const top10Mock = vi.fn()
vi.mock('../services/api', () => ({
leaderboardClient: {
top10: (): ReturnType<typeof top10Mock> => top10Mock(),
},
}))
describe('LeaderboardRoute', () => {
beforeEach(() => {
top10Mock.mockReset()
})
it('renders leaderboard rows via shared table', async () => {
top10Mock.mockResolvedValue([
{ player: 'Alice', score: 24, questions: 14, successRate: 86, durationSec: 1680 },
])
render(() => <LeaderboardRoute />)
await waitFor(() => {
@ -13,4 +29,16 @@ describe('LeaderboardRoute', () => {
expect(screen.getByRole('table', { name: 'top-10-leaderboard' })).toBeTruthy()
})
it('renders error feedback when leaderboard loading fails', async () => {
top10Mock.mockRejectedValue(new Error('boom'))
render(() => <LeaderboardRoute />)
await waitFor(() => {
expect(
screen.getByText('Impossible de charger le leaderboard. Veuillez réessayer.')
).toBeTruthy()
})
})
})

@ -1,4 +1,4 @@
import { createResource, type JSX } from 'solid-js'
import { createResource, createSignal, type JSX } from 'solid-js'
import { LeaderboardTable } from '@knowfoolery/ui-components'
import Box from '@suid/material/Box'
@ -11,7 +11,16 @@ import Typography from '@suid/material/Typography'
import { leaderboardClient } from '../services/api'
export default function LeaderboardRoute(): JSX.Element {
const [items, { refetch }] = createResource(async () => leaderboardClient.top10())
const [loadError, setLoadError] = createSignal<string | null>(null)
const [items, { refetch }] = createResource(async () => {
setLoadError(null)
try {
return await leaderboardClient.top10()
} catch {
setLoadError('Impossible de charger le leaderboard. Veuillez réessayer.')
return []
}
})
return (
<Box>
@ -27,6 +36,11 @@ export default function LeaderboardRoute(): JSX.Element {
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
{loadError() && (
<Typography color="error" sx={{ mb: 2 }}>
{loadError()}
</Typography>
)}
<LeaderboardTable loading={items.loading} rows={items() ?? []} />
</CardContent>
</Card>

@ -1,6 +1,11 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createAdminQuestion, deleteAdminQuestion, listAdminQuestions } from './adminQuestions'
import {
createAdminQuestion,
deleteAdminQuestion,
listAdminQuestions,
updateAdminQuestion,
} from './adminQuestions'
describe('adminQuestions service', () => {
afterEach(() => {
@ -23,4 +28,32 @@ describe('adminQuestions service', () => {
const afterDelete = listAdminQuestions()
expect(afterDelete.some((item) => item.id === created.id)).toBe(false)
})
it('updates an existing question', () => {
const created = createAdminQuestion({
theme: 'Science',
text: 'Question initiale',
answer: 'Réponse initiale',
hint: 'Indice initial',
difficulty: 'easy',
})
const updated = updateAdminQuestion(created.id, {
theme: 'Astronomie',
text: 'Question modifiée',
answer: 'Mars',
hint: 'Indice modifié',
difficulty: 'medium',
})
expect(updated).not.toBeNull()
const items = listAdminQuestions()
expect(items.find((item) => item.id === created.id)).toMatchObject({
theme: 'Astronomie',
text: 'Question modifiée',
answer: 'Mars',
hint: 'Indice modifié',
difficulty: 'medium',
})
})
})

@ -67,6 +67,26 @@ export function createAdminQuestion(input: CreateQuestionInput): AdminQuestion {
return next
}
export function updateAdminQuestion(id: string, input: CreateQuestionInput): AdminQuestion | null {
const current = listAdminQuestions()
const idx = current.findIndex((item) => item.id === id)
if (idx === -1) return null
const next: AdminQuestion = {
...current[idx],
theme: input.theme.trim(),
text: input.text.trim(),
answer: input.answer.trim(),
hint: input.hint.trim(),
difficulty: input.difficulty,
}
const updated = [...current]
updated[idx] = next
saveAdminQuestions(updated)
return next
}
export function deleteAdminQuestion(id: string): void {
const current = listAdminQuestions()
saveAdminQuestions(current.filter((item) => item.id !== id))

@ -10,4 +10,27 @@ describe('leaderboardClient', () => {
expect(rows[0]).toMatchObject({ player: 'Alice', score: 24 })
expect(rows[9]).toMatchObject({ player: 'Jules' })
})
it('loads API results when mode is set to api', async () => {
const originalFetch = global.fetch
const fetchMock = async (): Promise<Response> =>
new Response(
JSON.stringify({
items: [{ player: 'API', score: 12, questions: 4, successRate: 75, durationSec: 300 }],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
global.fetch = fetchMock as typeof fetch
localStorage.setItem('kf.leaderboard.mode', 'api')
const rows = await leaderboardClient.top10()
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ player: 'API', score: 12 })
localStorage.removeItem('kf.leaderboard.mode')
global.fetch = originalFetch
})
})

@ -7,36 +7,47 @@ export type LeaderboardRow = {
}
const baseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
const forceApi = import.meta.env.VITE_LEADERBOARD_FORCE_API === 'true'
const STORAGE_MODE_KEY = 'kf.leaderboard.mode'
async function json<T>(path: string): Promise<T> {
if (!baseUrl) {
throw new Error('API base URL not configured')
const fallbackTop10: LeaderboardRow[] = [
{ player: 'Alice', score: 24, questions: 14, successRate: 86, durationSec: 1680 },
{ player: 'Bob', score: 22, questions: 13, successRate: 85, durationSec: 1500 },
{ player: 'Charlie', score: 20, questions: 12, successRate: 83, durationSec: 1800 },
{ player: 'Diana', score: 18, questions: 11, successRate: 82, durationSec: 1320 },
{ player: 'Eve', score: 16, questions: 10, successRate: 80, durationSec: 1620 },
{ player: 'Farah', score: 15, questions: 9, successRate: 78, durationSec: 1730 },
{ player: 'Gino', score: 14, questions: 9, successRate: 74, durationSec: 1780 },
{ player: 'Hugo', score: 13, questions: 8, successRate: 75, durationSec: 1650 },
{ player: 'Ines', score: 12, questions: 8, successRate: 71, durationSec: 1600 },
{ player: 'Jules', score: 11, questions: 7, successRate: 70, durationSec: 1710 },
]
function hasStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
function shouldUseMockLeaderboard(): boolean {
if (forceApi) return false
if (hasStorage()) {
const mode = window.localStorage.getItem(STORAGE_MODE_KEY)
if (mode === 'api') return false
if (mode === 'mock') return true
}
const res = await fetch(`${baseUrl}${path}`)
return !baseUrl
}
async function json<T>(path: string): Promise<T> {
const url = baseUrl ? `${baseUrl}${path}` : path
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as T
}
export const leaderboardClient = {
async top10(): Promise<LeaderboardRow[]> {
// When the backend exists, this should call the leaderboard-service.
// For now, return a mock if API is not set.
if (!baseUrl) {
return [
{ player: 'Alice', score: 24, questions: 14, successRate: 86, durationSec: 1680 },
{ player: 'Bob', score: 22, questions: 13, successRate: 85, durationSec: 1500 },
{ player: 'Charlie', score: 20, questions: 12, successRate: 83, durationSec: 1800 },
{ player: 'Diana', score: 18, questions: 11, successRate: 82, durationSec: 1320 },
{ player: 'Eve', score: 16, questions: 10, successRate: 80, durationSec: 1620 },
{ player: 'Farah', score: 15, questions: 9, successRate: 78, durationSec: 1730 },
{ player: 'Gino', score: 14, questions: 9, successRate: 74, durationSec: 1780 },
{ player: 'Hugo', score: 13, questions: 8, successRate: 75, durationSec: 1650 },
{ player: 'Ines', score: 12, questions: 8, successRate: 71, durationSec: 1600 },
{ player: 'Jules', score: 11, questions: 7, successRate: 70, durationSec: 1710 },
]
}
if (shouldUseMockLeaderboard()) return fallbackTop10
// Example endpoint; adjust when gateway routes are finalized
const resp = await json<{ items?: LeaderboardRow[] }>(`/leaderboard/top10`)
return (resp.items ?? []).slice(0, 10)
},

@ -1 +1 @@
{"version":"1.6.1","results":[[":src/utils/timer.test.ts",{"duration":1,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":22,"failed":false}],[":src/components/ResultsCard.test.tsx",{"duration":64,"failed":false}],[":src/components/ThemeBadge.test.tsx",{"duration":19,"failed":false}],[":src/components/ScoreDisplay.test.tsx",{"duration":19,"failed":false}],[":src/components/Timer.test.tsx",{"duration":28,"failed":false}],[":src/components/GameCard.test.tsx",{"duration":34,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":30,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":38,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":74,"failed":false}]]}
{"version":"1.6.1","results":[[":src/components/ResultsCard.test.tsx",{"duration":64,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":28,"failed":false}],[":src/components/Timer.test.tsx",{"duration":25,"failed":false}],[":src/components/GameCard.test.tsx",{"duration":30,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":75,"failed":false}],[":src/utils/timer.test.ts",{"duration":1,"failed":false}],[":src/components/ThemeBadge.test.tsx",{"duration":18,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":20,"failed":false}],[":src/components/ScoreDisplay.test.tsx",{"duration":18,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":35,"failed":false}]]}

@ -7,6 +7,7 @@ export type AnswerInputProps = {
disabled?: boolean
label?: string
placeholder?: string
testId?: string
onInputValue: (nextValue: string) => void
onSubmit?: () => void
}
@ -29,6 +30,7 @@ const AnswerInput: Component<AnswerInputProps> = (props) => {
disabled={props.disabled}
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
inputProps={props.testId ? { 'data-testid': props.testId } : undefined}
onKeyDown={(event) => {
if (event.key === 'Enter') {
props.onSubmit?.()

Loading…
Cancel
Save