Finished step '4.2 Shared UI Components'
parent
609741bcde
commit
1b438b0ad1
@ -1 +1 @@
|
|||||||
{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/ui/Timer.test.ts",{"duration":2,"failed":false}]]}
|
{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":3,"failed":false}],[":src/routes/Results.test.tsx",{"duration":62,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":181,"failed":false}],[":src/routes/Game.test.tsx",{"duration":184,"failed":false}]]}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { render, screen } from '@solidjs/testing-library'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import GameRoute from './Game'
|
||||||
|
|
||||||
|
vi.mock('@solidjs/router', () => ({
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('GameRoute', () => {
|
||||||
|
it('renders shared game UI components', () => {
|
||||||
|
render(() => <GameRoute />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Partie')).toBeTruthy()
|
||||||
|
expect(screen.getByLabelText('Ta réponse')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Essais')).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Indice (score réduit)' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { render, screen, waitFor } from '@solidjs/testing-library'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import LeaderboardRoute from './Leaderboard'
|
||||||
|
|
||||||
|
describe('LeaderboardRoute', () => {
|
||||||
|
it('renders leaderboard rows via shared table', async () => {
|
||||||
|
render(() => <LeaderboardRoute />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Alice')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByRole('table', { name: 'top-10-leaderboard' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { render, screen } from '@solidjs/testing-library'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import ResultsRoute from './Results'
|
||||||
|
|
||||||
|
vi.mock('@solidjs/router', () => ({
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ResultsRoute', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'kf.lastResult',
|
||||||
|
JSON.stringify({
|
||||||
|
playerName: 'Alice',
|
||||||
|
finalScore: 10,
|
||||||
|
answered: 6,
|
||||||
|
correct: 5,
|
||||||
|
successRate: 83,
|
||||||
|
durationSec: 1200,
|
||||||
|
leaderboardPosition: 3,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders stored result using shared results card', () => {
|
||||||
|
render(() => <ResultsRoute />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Joueur : Alice')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Score final : 10')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Position leaderboard : #3')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,74 +1,24 @@
|
|||||||
import { useNavigate } from '@solidjs/router'
|
import { useNavigate } from '@solidjs/router'
|
||||||
import { Show, createMemo } from 'solid-js'
|
import { createMemo } from 'solid-js'
|
||||||
|
|
||||||
|
import { ResultsCard } from '@knowfoolery/ui-components'
|
||||||
import Box from '@suid/material/Box'
|
import Box from '@suid/material/Box'
|
||||||
import Button from '@suid/material/Button'
|
|
||||||
import Card from '@suid/material/Card'
|
|
||||||
import CardContent from '@suid/material/CardContent'
|
|
||||||
import Stack from '@suid/material/Stack'
|
|
||||||
import Typography from '@suid/material/Typography'
|
|
||||||
|
|
||||||
import { loadLastResult } from '../services/session'
|
import { loadLastResult } from '../services/session'
|
||||||
|
|
||||||
function formatDuration(durationSec: number): string {
|
|
||||||
const minutes = Math.floor(durationSec / 60)
|
|
||||||
const seconds = durationSec % 60
|
|
||||||
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResultsRoute() {
|
export default function ResultsRoute() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const result = createMemo(() => loadLastResult())
|
const result = createMemo(() => loadLastResult())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
|
<ResultsCard
|
||||||
<CardContent>
|
result={result()}
|
||||||
<Show
|
onPlayAgain={() => navigate('/game')}
|
||||||
when={result()}
|
onViewLeaderboard={() => navigate('/leaderboard')}
|
||||||
fallback={
|
onStartGame={() => navigate('/game')}
|
||||||
<Stack spacing={2}>
|
title="Résultats"
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800 }}>
|
/>
|
||||||
Résultats
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ opacity: 0.8 }}>Aucune partie terminée pour le moment.</Typography>
|
|
||||||
<Button variant="contained" onClick={() => navigate('/game')}>
|
|
||||||
Démarrer une partie
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(last) => (
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800 }}>
|
|
||||||
Résultats
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ opacity: 0.8 }}>Joueur : {last().playerName}</Typography>
|
|
||||||
|
|
||||||
<Typography>Score final : {last().finalScore}</Typography>
|
|
||||||
<Typography>
|
|
||||||
Questions répondues / correctes : {last().answered} / {last().correct}
|
|
||||||
</Typography>
|
|
||||||
<Typography>Taux de réussite : {last().successRate}%</Typography>
|
|
||||||
<Typography>Durée de session : {formatDuration(last().durationSec)}</Typography>
|
|
||||||
<Typography>
|
|
||||||
Position leaderboard :{' '}
|
|
||||||
{last().leaderboardPosition != null ? `#${last().leaderboardPosition}` : 'Hors top 10'}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
|
||||||
<Button variant="contained" onClick={() => navigate('/game')}>
|
|
||||||
Rejouer
|
|
||||||
</Button>
|
|
||||||
<Button variant="outlined" onClick={() => navigate('/leaderboard')}>
|
|
||||||
Voir le leaderboard
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import type { Component } from 'solid-js'
|
|
||||||
|
|
||||||
import Box from '@suid/material/Box'
|
|
||||||
import Stack from '@suid/material/Stack'
|
|
||||||
import Typography from '@suid/material/Typography'
|
|
||||||
|
|
||||||
const AttemptIndicator: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
|
|
||||||
const used = () => Math.max(0, Math.min(props.attemptsUsed, props.attemptsMax))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack direction="row" spacing={2} alignItems="center">
|
|
||||||
<Typography sx={{ opacity: 0.8 }}>Essais</Typography>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
{Array.from({ length: props.attemptsMax }).map((_, i) => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: i < used() ? '#ef4444' : '#334155',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
<Typography sx={{ opacity: 0.7, fontSize: 12 }}>
|
|
||||||
{Math.max(0, props.attemptsMax - used())} restant(s)
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AttemptIndicator
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import type { Component } from 'solid-js'
|
|
||||||
|
|
||||||
import Chip from '@suid/material/Chip'
|
|
||||||
|
|
||||||
const ScoreDisplay: Component<{ score: number }> = (props) => {
|
|
||||||
return <Chip label={`Score: ${props.score}`} color="primary" variant="outlined" />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ScoreDisplay
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { Show, type Component } from 'solid-js'
|
|
||||||
|
|
||||||
import Chip from '@suid/material/Chip'
|
|
||||||
import Stack from '@suid/material/Stack'
|
|
||||||
import Typography from '@suid/material/Typography'
|
|
||||||
|
|
||||||
export function formatMs(ms: number) {
|
|
||||||
const total = Math.ceil(ms / 1000)
|
|
||||||
const m = Math.floor(total / 60)
|
|
||||||
const s = total % 60
|
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTimerWarning(remainingMs: number): string | null {
|
|
||||||
if (remainingMs <= 10_000) return '10 secondes restantes'
|
|
||||||
if (remainingMs <= 60_000) return '1 minute restante'
|
|
||||||
if (remainingMs <= 300_000) return '5 minutes restantes'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const Timer: Component<{ remainingMs: number }> = (props) => {
|
|
||||||
const color = () => {
|
|
||||||
if (props.remainingMs <= 10_000) return 'error'
|
|
||||||
if (props.remainingMs <= 60_000) return 'warning'
|
|
||||||
if (props.remainingMs <= 300_000) return 'info'
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
const warning = () => getTimerWarning(props.remainingMs)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack spacing={0.5} alignItems="flex-end">
|
|
||||||
<Chip label={formatMs(props.remainingMs)} color={color() as any} variant="outlined" />
|
|
||||||
<Show when={warning()}>
|
|
||||||
<Typography variant="caption" sx={{ color: 'warning.main', fontWeight: 700 }}>
|
|
||||||
{warning()}
|
|
||||||
</Typography>
|
|
||||||
</Show>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Timer
|
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"version":"1.6.1","results":[[":src/utils/timer.test.ts",{"duration":7,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":24,"failed":false}],[":src/components/ResultsCard.test.tsx",{"duration":96,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":38,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":58,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":115,"failed":false}]]}
|
||||||
@ -1,32 +1,9 @@
|
|||||||
import type { Component } from 'solid-js'
|
import type { Component } from 'solid-js'
|
||||||
|
|
||||||
import Box from '@suid/material/Box'
|
import AttemptIndicator from './components/AttemptIndicator'
|
||||||
import Stack from '@suid/material/Stack'
|
|
||||||
import Typography from '@suid/material/Typography'
|
|
||||||
|
|
||||||
const AttemptDots: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
|
const AttemptDots: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
|
||||||
const used = () => Math.max(0, Math.min(props.attemptsUsed, props.attemptsMax))
|
return <AttemptIndicator attemptsUsed={props.attemptsUsed} attemptsMax={props.attemptsMax} />
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack direction="row" spacing={2} alignItems="center">
|
|
||||||
<Typography sx={{ opacity: 0.8 }}>Essais</Typography>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
{Array.from({ length: props.attemptsMax }).map((_, i) => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: i < used() ? '#ef4444' : '#334155',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
<Typography sx={{ opacity: 0.7, fontSize: 12 }}>
|
|
||||||
{Math.max(0, props.attemptsMax - used())} restant(s)
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AttemptDots
|
export default AttemptDots
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import type { Component } from 'solid-js'
|
import type { Component } from 'solid-js'
|
||||||
|
|
||||||
import Chip from '@suid/material/Chip'
|
import ScoreDisplay from './components/ScoreDisplay'
|
||||||
|
|
||||||
const ScoreChip: Component<{ score: number }> = (props) => {
|
const ScoreChip: Component<{ score: number }> = (props) => {
|
||||||
return <Chip label={`Score: ${props.score}`} color="primary" variant="outlined" />
|
return <ScoreDisplay score={props.score} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ScoreChip
|
export default ScoreChip
|
||||||
|
|||||||
@ -1,22 +1,9 @@
|
|||||||
import type { Component } from 'solid-js'
|
import type { Component } from 'solid-js'
|
||||||
|
|
||||||
import Chip from '@suid/material/Chip'
|
import Timer from './components/Timer'
|
||||||
|
|
||||||
function formatMs(ms: number) {
|
|
||||||
const total = Math.ceil(ms / 1000)
|
|
||||||
const m = Math.floor(total / 60)
|
|
||||||
const s = total % 60
|
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const TimerChip: Component<{ remainingMs: number }> = (props) => {
|
const TimerChip: Component<{ remainingMs: number }> = (props) => {
|
||||||
const color = () => {
|
return <Timer remainingMs={props.remainingMs} showWarning={false} />
|
||||||
if (props.remainingMs <= 10_000) return 'error'
|
|
||||||
if (props.remainingMs <= 60_000) return 'warning'
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Chip label={formatMs(props.remainingMs)} color={color() as any} variant="outlined" />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TimerChip
|
export default TimerChip
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { fireEvent, render, screen } from '@solidjs/testing-library'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import AnswerInput from './AnswerInput'
|
||||||
|
|
||||||
|
describe('AnswerInput', () => {
|
||||||
|
it('submits on Enter key', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const onInputValue = vi.fn()
|
||||||
|
|
||||||
|
render(() => <AnswerInput value="" onInputValue={onInputValue} onSubmit={onSubmit} />)
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Ta réponse')
|
||||||
|
await fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import type { Component } from 'solid-js'
|
||||||
|
|
||||||
|
import TextField from '@suid/material/TextField'
|
||||||
|
|
||||||
|
export type AnswerInputProps = {
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
onInputValue: (nextValue: string) => void
|
||||||
|
onSubmit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnswerInput: Component<AnswerInputProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label={props.label ?? 'Ta réponse'}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
value={props.value}
|
||||||
|
onInput={(event) => props.onInputValue((event.target as HTMLInputElement).value)}
|
||||||
|
fullWidth
|
||||||
|
disabled={props.disabled}
|
||||||
|
InputLabelProps={{ style: { color: '#cbd5e1' } }}
|
||||||
|
InputProps={{ style: { color: '#e5e7eb' } }}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
props.onSubmit?.()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnswerInput
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { render, screen } from '@solidjs/testing-library'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import AttemptIndicator from './AttemptIndicator'
|
||||||
|
|
||||||
|
describe('AttemptIndicator', () => {
|
||||||
|
it('clamps attempts and shows remaining count', () => {
|
||||||
|
render(() => <AttemptIndicator attemptsUsed={9} attemptsMax={3} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('0 restant(s)')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { For, type Component } from 'solid-js'
|
||||||
|
|
||||||
|
import Box from '@suid/material/Box'
|
||||||
|
import Stack from '@suid/material/Stack'
|
||||||
|
import Typography from '@suid/material/Typography'
|
||||||
|
|
||||||
|
export type AttemptIndicatorProps = {
|
||||||
|
attemptsUsed: number
|
||||||
|
attemptsMax?: number
|
||||||
|
label?: string
|
||||||
|
remainingFormatter?: (remaining: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttemptIndicator: Component<AttemptIndicatorProps> = (props) => {
|
||||||
|
const attemptsMax = () => Math.max(1, props.attemptsMax ?? 3)
|
||||||
|
const used = () => Math.max(0, Math.min(props.attemptsUsed, attemptsMax()))
|
||||||
|
const remaining = () => Math.max(0, attemptsMax() - used())
|
||||||
|
const formatRemaining = () => props.remainingFormatter?.(remaining()) ?? `${remaining()} restant(s)`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Typography sx={{ opacity: 0.8 }}>{props.label ?? 'Essais'}</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
<For each={Array.from({ length: attemptsMax() })}>
|
||||||
|
{(_, i) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: i() < used() ? '#ef4444' : '#334155',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Stack>
|
||||||
|
<Typography sx={{ opacity: 0.7, fontSize: 12 }}>{formatRemaining()}</Typography>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttemptIndicator
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import type { Component, JSXElement } from 'solid-js'
|
||||||
|
|
||||||
|
import Box from '@suid/material/Box'
|
||||||
|
import Card from '@suid/material/Card'
|
||||||
|
import CardContent from '@suid/material/CardContent'
|
||||||
|
import Divider from '@suid/material/Divider'
|
||||||
|
import Stack from '@suid/material/Stack'
|
||||||
|
import Typography from '@suid/material/Typography'
|
||||||
|
|
||||||
|
import ThemeBadge from './ThemeBadge'
|
||||||
|
|
||||||
|
export type GameCardProps = {
|
||||||
|
title?: string
|
||||||
|
theme: string
|
||||||
|
themeLabelPrefix?: string
|
||||||
|
questionText: string
|
||||||
|
timerSlot: JSXElement
|
||||||
|
scoreSlot: JSXElement
|
||||||
|
answerSlot: JSXElement
|
||||||
|
attemptSlot: JSXElement
|
||||||
|
actionsSlot: JSXElement
|
||||||
|
feedbackSlot?: JSXElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameCard: Component<GameCardProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800 }}>
|
||||||
|
{props.title ?? 'Partie'}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
|
<ThemeBadge theme={props.theme} labelPrefix={props.themeLabelPrefix ?? 'Theme: '} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{props.timerSlot}
|
||||||
|
{props.scoreSlot}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||||
|
{props.questionText}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{props.answerSlot}
|
||||||
|
{props.attemptSlot}
|
||||||
|
{props.actionsSlot}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{props.feedbackSlot}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GameCard
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import HintButton from './HintButton'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HintButton', () => {
|
||||||
|
it('opens a confirmation dialog then confirms', async () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
|
||||||
|
render(() => <HintButton onConfirm={onConfirm} />)
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Indice (score réduit)' }))
|
||||||
|
expect(screen.getByText("Confirmer l'indice")).toBeTruthy()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Oui, utiliser un indice', hidden: true }))
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import { createSignal, type Component } from 'solid-js'
|
||||||
|
|
||||||
|
import Button from '@suid/material/Button'
|
||||||
|
import Dialog from '@suid/material/Dialog'
|
||||||
|
import DialogActions from '@suid/material/DialogActions'
|
||||||
|
import DialogContent from '@suid/material/DialogContent'
|
||||||
|
import DialogTitle from '@suid/material/DialogTitle'
|
||||||
|
import Typography from '@suid/material/Typography'
|
||||||
|
|
||||||
|
export type HintButtonProps = {
|
||||||
|
disabled?: boolean
|
||||||
|
requiresConfirmation?: boolean
|
||||||
|
buttonLabel?: string
|
||||||
|
confirmTitle?: string
|
||||||
|
confirmMessage?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const HintButton: Component<HintButtonProps> = (props) => {
|
||||||
|
const [dialogOpen, setDialogOpen] = createSignal(false)
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
|
||||||
|
if (props.requiresConfirmation === false) {
|
||||||
|
props.onConfirm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
props.onConfirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outlined" disabled={props.disabled} onClick={handleClick}>
|
||||||
|
{props.buttonLabel ?? 'Indice (score réduit)'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen()} onClose={() => setDialogOpen(false)}>
|
||||||
|
<DialogTitle>{props.confirmTitle ?? 'Confirmer l\'indice'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
{props.confirmMessage ?? 'Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ?'}
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDialogOpen(false)}>{props.cancelLabel ?? 'Annuler'}</Button>
|
||||||
|
<Button variant="contained" onClick={confirm}>
|
||||||
|
{props.confirmLabel ?? 'Oui, utiliser un indice'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HintButton
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import { render, screen } from '@solidjs/testing-library'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import LeaderboardTable from './LeaderboardTable'
|
||||||
|
|
||||||
|
describe('LeaderboardTable', () => {
|
||||||
|
it('renders rows', () => {
|
||||||
|
render(() => (
|
||||||
|
<LeaderboardTable
|
||||||
|
rows={[
|
||||||
|
{ player: 'Alice', score: 20, questions: 10, successRate: 80, durationSec: 1200 },
|
||||||
|
{ player: 'Bob', score: 18, questions: 10, successRate: 70, durationSec: 1400 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(screen.getByText('Alice')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Bob')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty state', () => {
|
||||||
|
render(() => <LeaderboardTable rows={[]} emptyMessage="No data" />)
|
||||||
|
|
||||||
|
expect(screen.getByText('No data')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { For, Show, type Component } from 'solid-js'
|
||||||
|
|
||||||
|
import CircularProgress from '@suid/material/CircularProgress'
|
||||||
|
import Table from '@suid/material/Table'
|
||||||
|
import TableBody from '@suid/material/TableBody'
|
||||||
|
import TableCell from '@suid/material/TableCell'
|
||||||
|
import TableHead from '@suid/material/TableHead'
|
||||||
|
import TableRow from '@suid/material/TableRow'
|
||||||
|
import Typography from '@suid/material/Typography'
|
||||||
|
|
||||||
|
import type { LeaderboardRow } from '../types'
|
||||||
|
|
||||||
|
function formatDuration(durationSec: number): string {
|
||||||
|
const minutes = Math.floor(durationSec / 60)
|
||||||
|
return `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeaderboardTableProps = {
|
||||||
|
rows: LeaderboardRow[]
|
||||||
|
loading?: boolean
|
||||||
|
emptyMessage?: string
|
||||||
|
maxRows?: number
|
||||||
|
ariaLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeaderboardTable: Component<LeaderboardTableProps> = (props) => {
|
||||||
|
const maxRows = () => Math.max(1, props.maxRows ?? 10)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={!props.loading} fallback={<CircularProgress />}>
|
||||||
|
<Show when={props.rows.length > 0} fallback={<Typography sx={{ opacity: 0.8 }}>{props.emptyMessage ?? 'Aucun score pour le moment.'}</Typography>}>
|
||||||
|
<Table size="small" aria-label={props.ariaLabel ?? 'top-10-leaderboard'}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Rang</TableCell>
|
||||||
|
<TableCell>Joueur</TableCell>
|
||||||
|
<TableCell align="right">Score</TableCell>
|
||||||
|
<TableCell align="right">Questions</TableCell>
|
||||||
|
<TableCell align="right">Taux de réussite</TableCell>
|
||||||
|
<TableCell align="right">Durée</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
<For each={props.rows.slice(0, maxRows())}>
|
||||||
|
{(row, idx) => (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>#{idx() + 1}</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>{row.player}</TableCell>
|
||||||
|
<TableCell align="right">{row.score}</TableCell>
|
||||||
|
<TableCell align="right">{row.questions}</TableCell>
|
||||||
|
<TableCell align="right">{row.successRate}%</TableCell>
|
||||||
|
<TableCell align="right">{formatDuration(row.durationSec)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeaderboardTable
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import { render, screen } from '@solidjs/testing-library'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import ResultsCard from './ResultsCard'
|
||||||
|
|
||||||
|
describe('ResultsCard', () => {
|
||||||
|
it('renders completed result values', () => {
|
||||||
|
render(() => (
|
||||||
|
<ResultsCard
|
||||||
|
result={{
|
||||||
|
playerName: 'Alice',
|
||||||
|
finalScore: 12,
|
||||||
|
answered: 7,
|
||||||
|
correct: 6,
|
||||||
|
successRate: 86,
|
||||||
|
durationSec: 1234,
|
||||||
|
leaderboardPosition: 2,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
}}
|
||||||
|
onPlayAgain={vi.fn()}
|
||||||
|
onViewLeaderboard={vi.fn()}
|
||||||
|
onStartGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(screen.getByText('Joueur : Alice')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Score final : 12')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Position leaderboard : #2')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders fallback when result is null', () => {
|
||||||
|
render(() => (
|
||||||
|
<ResultsCard result={null} onPlayAgain={vi.fn()} onViewLeaderboard={vi.fn()} onStartGame={vi.fn()} />
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(screen.getByText('Aucune partie terminée pour le moment.')).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Démarrer une partie' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import { Show, type Component } from 'solid-js'
|
||||||
|
|
||||||
|
import Button from '@suid/material/Button'
|
||||||
|
import Card from '@suid/material/Card'
|
||||||
|
import CardContent from '@suid/material/CardContent'
|
||||||
|
import Stack from '@suid/material/Stack'
|
||||||
|
import Typography from '@suid/material/Typography'
|
||||||
|
|
||||||
|
import type { GameResult } from '../types'
|
||||||
|
|
||||||
|
function formatDuration(durationSec: number): string {
|
||||||
|
const minutes = Math.floor(durationSec / 60)
|
||||||
|
const seconds = durationSec % 60
|
||||||
|
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResultsCardProps = {
|
||||||
|
result: GameResult | null
|
||||||
|
onPlayAgain: () => void
|
||||||
|
onViewLeaderboard: () => void
|
||||||
|
onStartGame: () => void
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResultsCard: Component<ResultsCardProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Show
|
||||||
|
when={props.result}
|
||||||
|
fallback={
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800 }}>
|
||||||
|
{props.title ?? 'Résultats'}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ opacity: 0.8 }}>Aucune partie terminée pour le moment.</Typography>
|
||||||
|
<Button variant="contained" onClick={props.onStartGame}>
|
||||||
|
Démarrer une partie
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(last) => (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800 }}>
|
||||||
|
{props.title ?? 'Résultats'}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ opacity: 0.8 }}>Joueur : {last().playerName}</Typography>
|
||||||
|
|
||||||
|
<Typography>Score final : {last().finalScore}</Typography>
|
||||||
|
<Typography>
|
||||||
|
Questions répondues / correctes : {last().answered} / {last().correct}
|
||||||
|
</Typography>
|
||||||
|
<Typography>Taux de réussite : {last().successRate}%</Typography>
|
||||||
|
<Typography>Durée de session : {formatDuration(last().durationSec)}</Typography>
|
||||||
|
<Typography>
|
||||||
|
Position leaderboard : {last().leaderboardPosition != null ? `#${last().leaderboardPosition}` : 'Hors top 10'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
|
<Button variant="contained" onClick={props.onPlayAgain}>
|
||||||
|
Rejouer
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" onClick={props.onViewLeaderboard}>
|
||||||
|
Voir le leaderboard
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResultsCard
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import type { Component } from 'solid-js'
|
||||||
|
|
||||||
|
import Chip from '@suid/material/Chip'
|
||||||
|
|
||||||
|
const ScoreDisplay: Component<{ score: number; labelPrefix?: string }> = (props) => {
|
||||||
|
return <Chip label={`${props.labelPrefix ?? 'Score:'} ${props.score}`} color="primary" variant="outlined" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScoreDisplay
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import type { Component } from 'solid-js'
|
||||||
|
|
||||||
|
import Chip from '@suid/material/Chip'
|
||||||
|
|
||||||
|
const ThemeBadge: Component<{ theme: string; labelPrefix?: string }> = (props) => {
|
||||||
|
const label = () => (props.labelPrefix ? `${props.labelPrefix}${props.theme}` : props.theme)
|
||||||
|
|
||||||
|
return <Chip label={label()} variant="outlined" color="secondary" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeBadge
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { Show, type Component } from 'solid-js'
|
||||||
|
|
||||||
|
import Chip from '@suid/material/Chip'
|
||||||
|
import Stack from '@suid/material/Stack'
|
||||||
|
import Typography from '@suid/material/Typography'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_TIMER_WARNING_TEXT,
|
||||||
|
formatMs,
|
||||||
|
getTimerColor,
|
||||||
|
getTimerWarning,
|
||||||
|
type TimerWarningText,
|
||||||
|
} from '../utils/timer'
|
||||||
|
|
||||||
|
export type TimerProps = {
|
||||||
|
remainingMs: number
|
||||||
|
showWarning?: boolean
|
||||||
|
warningText?: Partial<TimerWarningText>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Timer: Component<TimerProps> = (props) => {
|
||||||
|
const mergedWarningText = () => ({ ...DEFAULT_TIMER_WARNING_TEXT, ...(props.warningText ?? {}) })
|
||||||
|
const warning = () => getTimerWarning(props.remainingMs, mergedWarningText())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={0.5} alignItems="flex-end">
|
||||||
|
<Chip label={formatMs(props.remainingMs)} color={getTimerColor(props.remainingMs)} variant="outlined" />
|
||||||
|
<Show when={props.showWarning !== false && warning()}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'warning.main', fontWeight: 700 }}>
|
||||||
|
{warning()}
|
||||||
|
</Typography>
|
||||||
|
</Show>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Timer
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
export type LeaderboardRow = {
|
||||||
|
player: string
|
||||||
|
score: number
|
||||||
|
questions: number
|
||||||
|
successRate: number
|
||||||
|
durationSec: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameResult = {
|
||||||
|
playerName: string
|
||||||
|
finalScore: number
|
||||||
|
answered: number
|
||||||
|
correct: number
|
||||||
|
successRate: number
|
||||||
|
durationSec: number
|
||||||
|
leaderboardPosition: number | null
|
||||||
|
finishedAt: string
|
||||||
|
}
|
||||||
@ -1,21 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { formatMs, getTimerWarning } from './Timer'
|
import { formatMs, getTimerWarning } from './timer'
|
||||||
|
|
||||||
describe('Timer helpers', () => {
|
describe('timer utils', () => {
|
||||||
it('formats remaining milliseconds as mm:ss', () => {
|
it('formats milliseconds as mm:ss', () => {
|
||||||
expect(formatMs(65_000)).toBe('1:05')
|
expect(formatMs(65_000)).toBe('1:05')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns the 5-minute warning', () => {
|
it('returns warning messages at thresholds', () => {
|
||||||
expect(getTimerWarning(299_000)).toBe('5 minutes restantes')
|
expect(getTimerWarning(299_000)).toBe('5 minutes restantes')
|
||||||
})
|
|
||||||
|
|
||||||
it('returns the 1-minute warning', () => {
|
|
||||||
expect(getTimerWarning(59_000)).toBe('1 minute restante')
|
expect(getTimerWarning(59_000)).toBe('1 minute restante')
|
||||||
|
expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns the 10-second warning', () => {
|
it('returns null when no warning should be shown', () => {
|
||||||
expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
|
expect(getTimerWarning(301_000)).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
export function formatMs(ms: number): string {
|
||||||
|
const total = Math.ceil(ms / 1000)
|
||||||
|
const minutes = Math.floor(total / 60)
|
||||||
|
const seconds = total % 60
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimerWarningText = {
|
||||||
|
fiveMinutes: string
|
||||||
|
oneMinute: string
|
||||||
|
tenSeconds: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TIMER_WARNING_TEXT: TimerWarningText = {
|
||||||
|
fiveMinutes: '5 minutes restantes',
|
||||||
|
oneMinute: '1 minute restante',
|
||||||
|
tenSeconds: '10 secondes restantes',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimerWarning(
|
||||||
|
remainingMs: number,
|
||||||
|
warningText: TimerWarningText = DEFAULT_TIMER_WARNING_TEXT
|
||||||
|
): string | null {
|
||||||
|
if (remainingMs <= 10_000) return warningText.tenSeconds
|
||||||
|
if (remainingMs <= 60_000) return warningText.oneMinute
|
||||||
|
if (remainingMs <= 300_000) return warningText.fiveMinutes
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimerColor(remainingMs: number): 'default' | 'info' | 'warning' | 'error' {
|
||||||
|
if (remainingMs <= 10_000) return 'error'
|
||||||
|
if (remainingMs <= 60_000) return 'warning'
|
||||||
|
if (remainingMs <= 300_000) return 'info'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import solid from 'vite-plugin-solid'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [solid()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue