diff --git a/TODO.md b/TODO.md index 078f8a9..c0530be 100644 --- a/TODO.md +++ b/TODO.md @@ -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 \ No newline at end of file diff --git a/src/components/play-view.test.tsx b/src/components/play-view.test.tsx new file mode 100644 index 0000000..87f3708 --- /dev/null +++ b/src/components/play-view.test.tsx @@ -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() + 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() + 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() + 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') +}) diff --git a/src/components/play-view.tsx b/src/components/play-view.tsx new file mode 100644 index 0000000..f5fb264 --- /dev/null +++ b/src/components/play-view.tsx @@ -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([]) + const [theme, setTheme] = useState(EMPTY_THEME) + const [card, setCard] = useState(null) + const [revealed, setRevealed] = useState(false) + const [error, setError] = useState(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 ( +
+
+
+ + +
+ +
+ + {error && ( +

{error}

+ )} + + {card && ( +
+ + {card.theme} + +

{card.question}

+ {revealed ? ( +
+

{card.response}

+ +
+ ) : ( +
+ +
+ )} +
+ )} + + {card === null && !error && ( +

Cliquez sur "Tirer une carte" pour commencer.

+ )} +
+ ) +}