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 { Show, createMemo } from 'solid-js'
|
||||
import { createMemo } from 'solid-js'
|
||||
|
||||
import { ResultsCard } from '@knowfoolery/ui-components'
|
||||
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'
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
const result = createMemo(() => loadLastResult())
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
|
||||
<CardContent>
|
||||
<Show
|
||||
when={result()}
|
||||
fallback={
|
||||
<Stack spacing={2}>
|
||||
<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>
|
||||
<ResultsCard
|
||||
result={result()}
|
||||
onPlayAgain={() => navigate('/game')}
|
||||
onViewLeaderboard={() => navigate('/leaderboard')}
|
||||
onStartGame={() => navigate('/game')}
|
||||
title="Résultats"
|
||||
/>
|
||||
</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 Box from '@suid/material/Box'
|
||||
import Stack from '@suid/material/Stack'
|
||||
import Typography from '@suid/material/Typography'
|
||||
import AttemptIndicator from './components/AttemptIndicator'
|
||||
|
||||
const AttemptDots: 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>
|
||||
)
|
||||
return <AttemptIndicator attemptsUsed={props.attemptsUsed} attemptsMax={props.attemptsMax} />
|
||||
}
|
||||
|
||||
export default AttemptDots
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { Component } from 'solid-js'
|
||||
|
||||
import Chip from '@suid/material/Chip'
|
||||
import ScoreDisplay from './components/ScoreDisplay'
|
||||
|
||||
const ScoreChip: Component<{ score: number }> = (props) => {
|
||||
return <Chip label={`Score: ${props.score}`} color="primary" variant="outlined" />
|
||||
return <ScoreDisplay score={props.score} />
|
||||
}
|
||||
|
||||
export default ScoreChip
|
||||
|
||||
@ -1,22 +1,9 @@
|
||||
import type { Component } from 'solid-js'
|
||||
|
||||
import Chip from '@suid/material/Chip'
|
||||
|
||||
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')}`
|
||||
}
|
||||
import Timer from './components/Timer'
|
||||
|
||||
const TimerChip: Component<{ remainingMs: number }> = (props) => {
|
||||
const color = () => {
|
||||
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" />
|
||||
return <Timer remainingMs={props.remainingMs} showWarning={false} />
|
||||
}
|
||||
|
||||
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 { formatMs, getTimerWarning } from './Timer'
|
||||
import { formatMs, getTimerWarning } from './timer'
|
||||
|
||||
describe('Timer helpers', () => {
|
||||
it('formats remaining milliseconds as mm:ss', () => {
|
||||
describe('timer utils', () => {
|
||||
it('formats milliseconds as mm:ss', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('returns the 1-minute warning', () => {
|
||||
expect(getTimerWarning(59_000)).toBe('1 minute restante')
|
||||
expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
|
||||
})
|
||||
|
||||
it('returns the 10-second warning', () => {
|
||||
expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
|
||||
it('returns null when no warning should be shown', () => {
|
||||
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