docs: p3-5 in progress)
This commit is contained in:
1 parent
208fe98f1b
commit
0233be7fff
3 files changed
+194
No files matched your search
@@ -183,3 +183,6 @@ Une feature est « faite » si et seulement si :
|
||||
- [ ] Selon cas : `Theme` en table dédiée vs dérivation des `theme` distincts
|
||||
- [ ] Choix par défaut de l'implémentation repository pour la prod (MySQL ou Mongo)
|
||||
- [ ] Authentification éventuelle (hors périmètre README, à confirmer si nécessaire)
|
||||
|
||||
|
||||
21/09: p3-5 en cours
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PlayView } from './play-view'
|
||||
|
||||
vi.mock('@/lib/api-client', () => {
|
||||
class ApiClientErrorMock extends Error {
|
||||
status = 0
|
||||
constructor(status: number, name: string, message: string) {
|
||||
super(message)
|
||||
this.name = name
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
return {
|
||||
ApiClientError: ApiClientErrorMock,
|
||||
cardsApi: { themes: vi.fn(), random: vi.fn() },
|
||||
}
|
||||
})
|
||||
|
||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||
|
||||
const CARD = {
|
||||
id: 'c1',
|
||||
question: 'Cest quoi un rebase ?',
|
||||
response: 'Réappliquer des commits sur une autre base',
|
||||
theme: 'git',
|
||||
tags: [],
|
||||
} as never
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(cardsApi.themes).mockReset()
|
||||
vi.mocked(cardsApi.random).mockReset()
|
||||
if (!vi.mocked(cardsApi.themes).mock.calls.length) {
|
||||
vi.mocked(cardsApi.themes).mockResolvedValue(['git', 'javascript'])
|
||||
}
|
||||
})
|
||||
|
||||
it('tire une carte au hasard globalement (F-01) puis masque la réponse (F-02, F-03)', async () => {
|
||||
vi.mocked(cardsApi.random).mockResolvedValue(CARD)
|
||||
render(<PlayView />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Tirer une carte' }))
|
||||
expect(await screen.findByText('Cest quoi un rebase ?')).toBeInTheDocument()
|
||||
expect(cardsApi.random).toHaveBeenCalledWith(undefined)
|
||||
expect(screen.queryByText(/R\u00e9appliquer des commits sur une autre base/)).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Voir la réponse' }))
|
||||
expect(screen.getByText(/R\u00e9appliquer des commits sur une autre base/)).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Masquer la réponse' }))
|
||||
expect(screen.queryByText(/R\u00e9appliquer des commits sur une autre base/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tire uniquement dans le thème choisi (F-04) et gère un tirage vide (F-10 erreur)', async () => {
|
||||
vi.mocked(cardsApi.random).mockResolvedValue(CARD)
|
||||
render(<PlayView />)
|
||||
await userEvent.selectOptions(await screen.findByLabelText('Thème'), 'javascript')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Tirer une carte' }))
|
||||
expect(await screen.findByText('Cest quoi un rebase ?')).toBeInTheDocument()
|
||||
expect(cardsApi.random).toHaveBeenCalledWith('javascript')
|
||||
})
|
||||
|
||||
it('affiche un message si le thème ne contient aucune carte', async () => {
|
||||
vi.mocked(cardsApi.random).mockRejectedValue(new ApiClientError(404, 'NotFoundError', 'Aucune carte'))
|
||||
render(<PlayView />)
|
||||
await userEvent.selectOptions(await screen.findByLabelText('Thème'), 'vide')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Tirer une carte' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Aucune carte')
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||
import type { Card, CardInput } from '@/domain/card'
|
||||
|
||||
const EMPTY_THEME = ''
|
||||
|
||||
export function PlayView() {
|
||||
const [themes, setThemes] = useState<string[]>([])
|
||||
const [theme, setTheme] = useState<string>(EMPTY_THEME)
|
||||
const [card, setCard] = useState<Card | null>(null)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [drawing, setDrawing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
cardsApi
|
||||
.themes()
|
||||
.then((data) => {
|
||||
if (!cancelled) setThemes(data)
|
||||
})
|
||||
.catch(() => {
|
||||
/* thèmes optionnels : le jeu reste utilisable en tirage global */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const draw = useCallback(async () => {
|
||||
setDrawing(true)
|
||||
setRevealed(false)
|
||||
setError(null)
|
||||
try {
|
||||
const picked = await cardsApi.random(theme === EMPTY_THEME ? undefined : theme)
|
||||
setCard(picked)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
setError(e.message)
|
||||
setCard(null)
|
||||
}
|
||||
else {
|
||||
setError('Impossible de tirer une carte')
|
||||
setCard(null)
|
||||
}
|
||||
} finally {
|
||||
setDrawing(false)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<section aria-label="Jeu de révision" className="mx-auto max-w-2xl px-4 py-8">
|
||||
<div className="mb-6 flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label htmlFor="play-theme" className="block text-sm font-medium text-slate-700">
|
||||
Thème
|
||||
</label>
|
||||
<select
|
||||
id="play-theme"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
className="mt-1 rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value={EMPTY_THEME}>Tous les thèmes</option>
|
||||
{themes.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void draw()}
|
||||
disabled={drawing}
|
||||
className="rounded-lg bg-slate-900 px-5 py-2 text-sm font-semibold text-white hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{drawing ? 'Tirage…' : 'Tirer une carte'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
{card && (
|
||||
<article className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
||||
{card.theme}
|
||||
</span>
|
||||
<h2 className="mt-3 text-xl font-bold text-slate-900">{card.question}</h2>
|
||||
{revealed ? (
|
||||
<div className="mt-4 rounded-xl bg-slate-50 p-4">
|
||||
<p className="text-slate-700">{card.response}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed(false)}
|
||||
className="mt-3 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100"
|
||||
>
|
||||
Masquer la réponse
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed(true)}
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Voir la réponse
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
|
||||
{card === null && !error && (
|
||||
<p className="text-slate-500">Cliquez sur "Tirer une carte" pour commencer.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user