feat(ui): vue jeu branchée avec accueil et navigation (P3, F-01 à F-04, F-10)

This commit is contained in:
devcodetools committed 2026-09-22 08:38:59 +02:00
1 parent 0233be7fff
commit 61eef32c74
9 files changed
+136 -12

No files matched your search

+2
View File
@@ -1,6 +1,7 @@
import { SerwistProvider } from '@serwist/turbopack/react'
import type { Metadata, Viewport } from 'next'
import type { ReactNode } from 'react'
import { AppHeader } from '@/components/app-header'
import './globals.css'
const APP_NAME = 'flashcards'
@@ -55,6 +56,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<html lang="fr">
<body>
<SerwistProvider swUrl="/serwist/sw.js" disable={process.env.NODE_ENV === 'development'}>
<AppHeader />
{children}
</SerwistProvider>
</body>
+12 -2
View File
@@ -3,8 +3,18 @@ import { describe, expect, it } from 'vitest'
import Home from './page'
describe('Home', () => {
it('affiche le contenu minimal', () => {
it('affiche un titre et un lien vers le jeu', () => {
render(<Home />)
expect(screen.getByRole('heading', { level: 1, name: 'Hello world' })).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 1, name: /R\u00e9viser tes cartes/ }),
).toBeInTheDocument()
const playLink = screen.getByRole('link', { name: /Jouer/ })
expect(playLink).toHaveAttribute('href', '/play')
})
it('affiche un lien vers la gestion des cartes', () => {
render(<Home />)
const cardsLink = screen.getByRole('link', { name: /G\u00e9rer les cartes/ })
expect(cardsLink).toHaveAttribute('href', '/cards')
})
})
+30 -2
View File
@@ -1,7 +1,35 @@
import Link from 'next/link'
export default function Home() {
return (
<main>
<h1>Hello world</h1>
<main className="mx-auto max-w-2xl px-4 py-16">
<h1 className="text-3xl font-bold text-slate-900">
Réviser tes cartes, une carte à la fois.
</h1>
<p className="mt-4 text-slate-600">
Tire une carte au hasard, révèle la réponse quand tu es prêt, ou choisis un thème pour
cibler ta révision.
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<Link
href="/play"
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition hover:border-slate-300 hover:shadow-md"
>
<span className="text-lg font-semibold text-slate-900">Jouer</span>
<span className="mt-1 block text-sm text-slate-600">
Tirage aléatoire d&apos;une carte, par thème ou globalement.
</span>
</Link>
<Link
href="/cards"
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition hover:border-slate-300 hover:shadow-md"
>
<span className="text-lg font-semibold text-slate-900">Gérer les cartes</span>
<span className="mt-1 block text-sm text-slate-600">
Créer, modifier, consulter et supprimer tes cartes.
</span>
</Link>
</div>
</main>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PlayPage from './page'
vi.mock('@/lib/api-client', () => ({
ApiClientError: class extends Error {
status = 0
},
cardsApi: { themes: vi.fn(), random: vi.fn() },
}))
import { cardsApi } from '@/lib/api-client'
describe('PlayPage', () => {
it('monte le jeu avec le sélecteur de thème et le tirage (F-01 à F-04, F-10)', async () => {
vi.mocked(cardsApi.themes).mockResolvedValue(['git'])
render(<PlayPage />)
expect(screen.getByRole('button', { name: 'Tirer une carte' })).toBeInTheDocument()
expect(screen.getByLabelText('Th\u00e8me')).toBeInTheDocument()
await screen.findByRole('option', { name: 'git' })
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
import { PlayView } from '@/components/play-view'
export const metadata: Metadata = {
title: 'Jouer',
}
export default function PlayPage() {
return (
<main>
<PlayView />
</main>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { AppHeader } from './app-header'
describe('AppHeader', () => {
it('affiche la marque et les liens de navigation', () => {
render(<AppHeader />)
expect(screen.getByRole('link', { name: 'Flashcards' })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: 'Jouer' })).toHaveAttribute('href', '/play')
expect(screen.getByRole('link', { name: 'G\u00e9rer les cartes' })).toHaveAttribute(
'href',
'/cards',
)
})
})
+25
View File
@@ -0,0 +1,25 @@
import Link from 'next/link'
export function AppHeader() {
return (
<header className="border-b border-slate-200 bg-white">
<nav className="mx-auto flex max-w-5xl items-center gap-6 px-4 py-3">
<Link href="/" className="text-lg font-bold text-slate-900">
Flashcards
</Link>
<Link
href="/play"
className="text-sm font-medium text-slate-600 underline-offset-4 hover:underline"
>
Jouer
</Link>
<Link
href="/cards"
className="text-sm font-medium text-slate-600 underline-offset-4 hover:underline"
>
Gérer les cartes
</Link>
</nav>
</header>
)
}
+11 -4
View File
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, expect, it, vi } from 'vitest'
import { PlayView } from './play-view'
vi.mock('@/lib/api-client', () => {
@@ -42,11 +42,15 @@ it('tire une carte au hasard globalement (F-01) puis masque la réponse (F-02, F
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()
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()
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 () => {
@@ -59,7 +63,10 @@ it('tire uniquement dans le thème choisi (F-04) et gère un tirage vide (F-10 e
})
it('affiche un message si le thème ne contient aucune carte', async () => {
vi.mocked(cardsApi.random).mockRejectedValue(new ApiClientError(404, 'NotFoundError', 'Aucune carte'))
vi.mocked(cardsApi.themes).mockResolvedValue(['git', 'javascript', 'vide'])
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' }))
+5 -4
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react'
import { ApiClientError, cardsApi } from '@/lib/api-client'
import type { Card, CardInput } from '@/domain/card'
import type { Card } from '@/domain/card'
const EMPTY_THEME = ''
@@ -40,8 +40,7 @@ export function PlayView() {
if (e instanceof ApiClientError) {
setError(e.message)
setCard(null)
}
else {
} else {
setError('Impossible de tirer une carte')
setCard(null)
}
@@ -82,7 +81,9 @@ export function PlayView() {
</div>
{error && (
<p role="alert" className="text-red-600">{error}</p>
<p role="alert" className="text-red-600">
{error}
</p>
)}
{card && (