Cours JavaScript et React · Module 13: Tester React
Projet principal : une suite de tests complète pour le centre de contrôle de mission cosmique
Dans cette leçon8
Le moment est venu de récapituler tout ce qu'on a appris dans ce module ! Le Commandant Nova prépare la flotte au lancement, et le centre de contrôle de mission est un ensemble de panneaux qui doivent fonctionner sans faille. Chaque modification du code peut casser en silence le formulaire de l'équipage ou l'alarme des boucliers, et personne ne clique sur toute l'application avant chaque lancement. Ta mission est de protéger cette application avec une suite de tests complète, c'est-à-dire de réunir dans un seul projet toutes les techniques de ce module.
Description de l'application
Tu trouveras une version qui fonctionne de l'application dans l'aperçu à la fin de la leçon. Le centre de contrôle de mission se compose des éléments suivants :
1. MissionDashboard : le tableau des missions
- Charge la liste des missions avec la fonction
loadMissionsqu'il reçoit en props - Affiche un état de chargement, une erreur avec un bouton pour réessayer, et les données chargées
- Filtre les missions par statut
- Affiche un résumé : le nombre total de missions et le nombre de missions actives
2. CrewManager : la gestion de l'équipage
- Un formulaire pour ajouter des membres d'équipage avec les champs Nom, Rôle et Expérience (années)
- La validation des données avec la fonction
validateCrewMember(nom, rôle, expérience) - Une liste des membres avec des boutons de suppression et un filtre par rôle
3. SystemStatus : l'état des systèmes
- Affiche les niveaux des systèmes du vaisseau sous forme de barres avec
role="progressbar" - Lit les capteurs toutes les 10 secondes : chaque lecture fait perdre 15 points aux boucliers
- Déclenche une alarme avec
role="alert"quand un niveau descend sous 30 %
4. Hooks personnalisés
useMissions: chargement des missions, état de chargement et d'erreur, nouvel essaiuseSystemHealth: lecture des systèmes à intervalle régulieruseCrewFilter: filtrage de l'équipage par rôle
Comment travailler avec ce projet
L'aperçu montre l'application en marche, mais il n'exécute pas Jest. Les tests de cette leçon sont écrits pour exactement ces composants : copie le code de l'aperçu dans ton propre projet, répartis-le dans des fichiers selon la structure ci-dessous et lance npm test. Configure le projet comme dans la première leçon du module : Jest avec l'environnement jsdom, une transformation du JSX et @testing-library/jest-dom dans le fichier de setup.
Tâches à réaliser
Ta suite de tests doit couvrir tous les niveaux que tu as découverts dans le module :
- Tests unitaires : fonctions utilitaires, validation
- Tests de composants : rendu, interactions, états
- Tests asynchrones : récupération de données, chargement, erreurs
- Tests de hooks : hooks personnalisés avec
renderHook - Mocking d'API : doublures
jest.fn(),jest.mock, mocking defetch - Snapshots : composants de présentation clés
Structure des tests
Chaque composant, chaque hook et chaque fichier de fonctions utilitaires a son propre fichier de tests : quand un test échoue, tu sais tout de suite où chercher. Jest les trouve tout seul, car ils se trouvent dans le dossier __tests__ et se terminent par .test.js :
1__tests__/
2 MissionDashboard.test.js -- tests du tableau des missions
3 CrewManager.test.js -- tests de la gestion d'équipage
4 SystemStatus.test.js -- tests de l'état des systèmes
5 hooks/
6 useMissions.test.js -- tests du hook des missions
7 useCrewFilter.test.js -- tests du hook de filtrage de l'équipage
8 utils/
9 validators.test.js -- tests des fonctions de validationLe dossier __tests__ se trouve dans src à côté des composants, et tu ranges les hooks et les fonctions utilitaires dans src/hooks et src/utils. Chacun de ces fichiers exporte ce que tu testes, et les imports des exemples ci-dessous supposent exactement cette organisation.
Patterns clés à appliquer
Les patterns ci-dessous couvrent toute la suite : de la fonction pure au hook et au composant, jusqu'à l'asynchrone, au temps et aux erreurs. Chaque exemple fonctionne avec le code de l'aperçu. Les noms des tests restent en anglais, comme dans tout le module.
Pattern : tester une fonction pure
La validation n'a pas besoin de React : elle reçoit un objet et renvoie une liste d'erreurs, donc tu la testes comme les fonctions de la leçon sur Jest :
1import { validateCrewMember } from '../../utils/validators';
2
3test('rejects a one-letter name and an unknown role', () => {
4 const errors = validateCrewMember({ name: 'X', role: 'cook', experience: 3 });
5
6 expect(errors).toEqual(['Le nom doit contenir au moins 2 caractères', 'Rôle inconnu']);
7});toEqual compare le contenu du tableau, et non la référence, donc le test vérifie exactement les deux messages que l'utilisateur verra. C'est le test le moins cher et le plus rapide de toute la suite, la base de la pyramide des tests.
Pattern : tester un hook
Tu lances le hook useCrewFilter avec renderHook et tu enveloppes le changement de filtre dans act, comme dans la leçon sur les hooks :
1import { renderHook, act } from '@testing-library/react';
2import { useCrewFilter } from '../../hooks/useCrewFilter';
3
4test('filters crew by role', () => {
5 const crew = [
6 { id: 1, name: 'Astro', role: 'pilot' },
7 { id: 2, name: 'Commandant Nova', role: 'captain' },
8 ];
9 const { result } = renderHook(() => useCrewFilter(crew));
10
11 act(() => result.current.setFilter('pilot'));
12
13 expect(result.current.filtered).toEqual([crew[0]]);
14});Tu lis le résultat dans result.current seulement après act, donc tu vois l'état après le changement de filtre : seul le pilote reste dans la liste. Le hook ne sait rien du formulaire ni des boutons, c'est pourquoi son test est court et résiste aux changements d'apparence.
Pattern : Arrange-Act-Assert
Chaque test de composant a trois parties. D'abord tu prépares la scène (Arrange), puis tu effectues une action comme un utilisateur (Act), et enfin tu vérifies le résultat (Assert). Les commentaires du test montrent ce découpage :
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import CrewManager from '../CrewManager';
4
5test('adds new crew member', async () => {
6 // Arrange : préparer
7 const user = userEvent.setup();
8 render(<CrewManager />);
9
10 // Act : exécuter
11 await user.type(screen.getByLabelText('Nom'), 'Ra');
12 await user.selectOptions(screen.getByLabelText('Rôle'), 'capitaine');
13 await user.click(screen.getByRole('button', { name: 'Ajouter un membre' }));
14
15 // Assert : vérifier
16 expect(screen.getByText('Ra (capitaine, 0 an)')).toBeInTheDocument();
17});getByLabelText trouve les champs, car les étiquettes leur sont reliées par htmlFor et id. L'assertion cherche toute l'entrée de la liste, et non le seul mot « capitaine », car ce mot est aussi une option de la liste déroulante et le test passerait même si l'ajout ne fonctionnait pas. Ajoute un second test pour le chemin d'erreur : le nom X doit afficher un message dans un élément avec role="alert".
Pattern : isolation des tests
Les tests d'un même fichier ne doivent pas dépendre les uns des autres. Une doublure partagée par plusieurs tests garde en mémoire les appels des précédents, donc sans nettoyage le compteur du premier test ferait échouer l'assertion du second. C'est pourquoi beforeEach efface l'historique avant chaque test :
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import MissionDashboard from '../MissionDashboard';
4
5const missions = [{ id: 1, name: 'Apollo 11', status: 'completed', crew: 3 }];
6const loadMissions = jest.fn();
7
8beforeEach(() => {
9 // Chaque test démarre avec un historique d'appels vide
10 jest.clearAllMocks();
11});
12
13test('shows missions after loading', async () => {
14 loadMissions.mockResolvedValue(missions);
15 render(<MissionDashboard loadMissions={loadMissions} />);
16
17 expect(screen.getByRole('status')).toHaveTextContent('Chargement des missions...');
18 expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
19 expect(loadMissions).toHaveBeenCalledTimes(1);
20});Le tableau reçoit la fonction loadMissions en props : dans le test, tu passes une doublure jest.fn() au lieu de la vraie API, et dans l'aperçu la même prop reçoit un serveur simulé. On voit d'abord l'état de chargement avec role="status", puis findByText attend que les données apparaissent. Le second test vérifie le chemin d'erreur et le nouvel essai :
1test('shows an error and retries', async () => {
2 const user = userEvent.setup();
3 loadMissions
4 .mockRejectedValueOnce(new Error('Pas de connexion avec le serveur des missions'))
5 .mockResolvedValueOnce(missions);
6 render(<MissionDashboard loadMissions={loadMissions} />);
7
8 expect(await screen.findByRole('alert')).toHaveTextContent('Pas de connexion');
9 await user.click(screen.getByRole('button', { name: 'Réessayer' }));
10
11 expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
12 expect(loadMissions).toHaveBeenCalledTimes(2);
13});Les deux appels Once définissent les réponses successives : d'abord une panne, puis des données. Sans jest.clearAllMocks() dans beforeEach, ce test compterait aussi l'appel du test précédent et verrait 3 au lieu de 2. Quand un composant appelle lui-même fetch, ajoute aussi global.fetch = jest.fn() dans beforeEach, comme dans la leçon sur le mocking.
Pattern : le temps sous contrôle
L'alarme des boucliers n'apparaît qu'après deux lectures des capteurs, soit au bout de 20 secondes. Les fake timers font défiler ce temps en une fraction de seconde :
1import { act } from 'react';
2import { render, screen } from '@testing-library/react';
3import SystemStatus from '../SystemStatus';
4
5test('raises an alert when shields drop below 30%', () => {
6 jest.useFakeTimers();
7 render(<SystemStatus />);
8 expect(screen.queryByRole('alert')).not.toBeInTheDocument();
9
10 // Deux lectures des capteurs : 55 % -> 40 % -> 25 %
11 act(() => {
12 jest.advanceTimersByTime(20000);
13 });
14
15 expect(screen.getByRole('progressbar', { name: 'Boucliers' })).toHaveAttribute('aria-valuenow', '25');
16 expect(screen.getByRole('alert')).toHaveTextContent('Boucliers');
17 jest.useRealTimers();
18});queryByRole au début confirme qu'il n'y a pas encore d'alarme, car getByRole lancerait une erreur à cet endroit. Avancer l'horloge modifie l'état en dehors des outils de RTL, c'est pourquoi on le place dans act. Tu trouves la barre par le rôle progressbar et le nom donné par aria-label, et tu lis sa valeur dans aria-valuenow.
Pattern : tester une Error Boundary
Une Error Boundary est un composant qui attrape une erreur lancée pendant le rendu de ses enfants et affiche un message de secours au lieu d'un écran vide. React n'a pas de hook pour cela, donc tu écris la boundary sous forme de composant de classe avec une méthode static getDerivedStateFromError, ou tu en prends une toute faite dans la bibliothèque react-error-boundary :
1import { Component } from 'react';
2
3class ErrorBoundary extends Component {
4 state = { hasError: false };
5
6 static getDerivedStateFromError() {
7 return { hasError: true };
8 }
9
10 render() {
11 return this.state.hasError ? this.props.fallback : this.props.children;
12 }
13}getDerivedStateFromError passe hasError à vrai, et render affiche alors l'élément de la prop fallback. Le test donne à la boundary un composant qui lance toujours une erreur :
1test('shows error boundary on crash', () => {
2 const ThrowError = () => {
3 throw new Error('Kaboom!');
4 };
5
6 // React 19 signale une erreur attrapée par une boundary avec console.error
7 const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
8
9 render(
10 <ErrorBoundary fallback={<p>Une erreur est survenue</p>}>
11 <ThrowError />
12 </ErrorBoundary>
13 );
14
15 expect(screen.getByText('Une erreur est survenue')).toBeInTheDocument();
16 spy.mockRestore();
17});Dans React 19, une erreur attrapée par une boundary part dans console.error : sans l'espion, le test passe, mais il encombre le résultat d'une pile d'appels en rouge. mockRestore() à la fin rend à la console sa vraie fonction, pour que les tests suivants voient de nouveau les avertissements.
Bonnes pratiques de test
- Tester le comportement, pas l'implémentation : vérifier ce que l'utilisateur voit
- Un concept par test : chaque test vérifie une seule chose
- Des noms de tests lisibles :
it('displays error when mission not found') - DRY dans les tests avec modération : la duplication dans les tests vaut mieux que des abstractions trop compliquées
- Tester les chemins de succès et d'erreur : le scénario où tout se passe bien et les cas limites
- Ne pas tester le framework : ne teste pas si
useStatefonctionne, teste ta logique
Ce que tu rends à ton mentor
Après cette leçon, un projet pour ton mentor t'attend. Construis ta propre application React ou enrichis le centre de contrôle de mission, et prépare pour elle une suite de tests qui couvre :
- un tableau avec des données venant d'une API (une doublure à la place du vrai serveur dans les tests), avec la gestion du chargement et des erreurs
- un formulaire avec validation, vérifié avec
userEventet des messages avecrole="alert" - au moins un hook personnalisé testé avec
renderHook - des tests unitaires, des tests d'intégration de plusieurs composants et un snapshot d'un composant de présentation simple
- les chemins de succès et les cas limites
Pousse le projet et ses tests sur GitHub, puis envoie le lien du dépôt. Ton mentor lancera npm test, alors vérifie que tous les tests passent sur une copie toute neuve du projet.
Examine l'aperçu
Ci-dessous, tu trouveras le centre de contrôle de mission en marche. Clique partout et compare avec les tests de la leçon :
- clique sur Simuler une panne du serveur : le tableau affiche une erreur et un bouton Réessayer, comme dans le test du chemin d'erreur, et après un clic sur Rétablir le serveur il recharge les missions
- ajoute un membre d'équipage avec un nom d'une seule lettre et regarde les messages de validation
- attends 20 secondes que les boucliers descendent sous 30 % et que l'alarme apparaisse
Mon conseil : avant d'écrire un test, déroule le scénario dans l'aperçu et note-le en trois étapes : Arrange, Act et Assert. Le test s'écrit alors presque tout seul. Retiens : une bonne suite de tests est une vérification avant le lancement qui inspecte tout le vaisseau à chaque modification du code, avant que tu partes dans le vrai cosmos.
Code de cette leçon : App.jsx
1import React, { useState, useEffect, useCallback } from 'react';
2
3// ===== API simulée =====
4// L’aperçu n’a pas de serveur, une Promise avec setTimeout simule donc la réponse.
5// Dans les tests de la leçon, MissionDashboard reçoit à la place un mock jest.fn().
6const MISSIONS = [
7 { id: 1, name: 'Apollo 11', status: 'completed', crew: 3 },
8 { id: 2, name: 'Artemis II', status: 'active', crew: 4 },
9 { id: 3, name: 'Europa Clipper', status: 'active', crew: 0 },
10 { id: 4, name: 'Mars Sample Return', status: 'planned', crew: 0 },
11];
12
13function fetchMissions(serverDown) {
14 return new Promise((resolve, reject) => {
15 setTimeout(() => {
16 if (serverDown) reject(new Error('Pas de connexion avec le serveur des missions'));
17 else resolve(MISSIONS);
18 }, 800);
19 });
20}
21
22// ===== Fonctions utilitaires =====
23const ROLES = { captain: 'capitaine', pilot: 'pilote', engineer: 'ingénieur', scientist: 'scientifique' };
24const FILTER_LABELS = { all: 'toutes', active: 'actives', completed: 'terminées', planned: 'prévues' };
25const STATUS_LABELS = { active: 'active', completed: 'terminée', planned: 'prévue' };
26const SYSTEM_LABELS = { engine: 'Moteur', shields: 'Boucliers', navigation: 'Navigation', lifeSupport: 'Support vital' };
27
28function validateCrewMember(member) {
29 const errors = [];
30 if (!member.name || member.name.trim().length < 2) {
31 errors.push('Le nom doit contenir au moins 2 caractères');
32 }
33 if (!Object.keys(ROLES).includes(member.role)) {
34 errors.push('Rôle inconnu');
35 }
36 if (member.experience < 0 || member.experience > 50) {
37 errors.push('L’expérience doit être comprise entre 0 et 50 ans');
38 }
39 return errors;
40}
41
42function yearsLabel(n) {
43 return n <= 1 ? `${n} an` : `${n} ans`;
44}
45
46// ===== Hooks personnalisés =====
47function useMissions(loadMissions) {
48 const [missions, setMissions] = useState([]);
49 const [loading, setLoading] = useState(true);
50 const [error, setError] = useState(null);
51 const [attempt, setAttempt] = useState(0);
52
53 useEffect(() => {
54 let ignore = false;
55 loadMissions()
56 .then(data => {
57 if (!ignore) setMissions(data);
58 })
59 .catch(err => {
60 if (!ignore) setError(err.message);
61 })
62 .finally(() => {
63 if (!ignore) setLoading(false);
64 });
65 return () => {
66 ignore = true;
67 };
68 }, [loadMissions, attempt]);
69
70 // Nouvel essai : on affiche tout de suite le chargement et on recharge
71 const refetch = useCallback(() => {
72 setLoading(true);
73 setError(null);
74 setAttempt(a => a + 1);
75 }, []);
76 return { missions, loading, error, refetch };
77}
78
79function useCrewFilter(crew) {
80 const [filter, setFilter] = useState('all');
81 const filtered = filter === 'all' ? crew : crew.filter(member => member.role === filter);
82 return { filtered, filter, setFilter };
83}
84
85function useSystemHealth(intervalMs = 10000) {
86 const [systems, setSystems] = useState({ engine: 98, shields: 55, navigation: 100, lifeSupport: 92 });
87
88 useEffect(() => {
89 // À chaque lecture des capteurs, les boucliers perdent 15 points
90 const id = setInterval(() => {
91 setSystems(s => ({ ...s, shields: Math.max(s.shields - 15, 0) }));
92 }, intervalMs);
93 return () => clearInterval(id);
94 }, [intervalMs]);
95
96 const repairShields = () => setSystems(s => ({ ...s, shields: 100 }));
97 return { systems, repairShields };
98}
99
100// ===== Composants =====
101function MissionDashboard({ loadMissions }) {
102 const { missions, loading, error, refetch } = useMissions(loadMissions);
103 const [statusFilter, setStatusFilter] = useState('all');
104
105 if (loading) {
106 return (
107 <section className="panel">
108 <h2>Tableau des missions</h2>
109 <p className="loading" role="status">Chargement des missions...</p>
110 </section>
111 );
112 }
113
114 if (error) {
115 return (
116 <section className="panel">
117 <h2>Tableau des missions</h2>
118 <p className="error" role="alert">Erreur : {error}</p>
119 <button className="refresh-btn" onClick={refetch}>Réessayer</button>
120 </section>
121 );
122 }
123
124 const visible = statusFilter === 'all' ? missions : missions.filter(m => m.status === statusFilter);
125
126 return (
127 <section className="panel">
128 <h2>Tableau des missions</h2>
129 <div className="stats">
130 <div className="stat">Total : {missions.length}</div>
131 <div className="stat">Actives : {missions.filter(m => m.status === 'active').length}</div>
132 </div>
133
134 <div className="filters">
135 {Object.keys(FILTER_LABELS).map(s => (
136 <button key={s} aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}>
137 {FILTER_LABELS[s]}
138 </button>
139 ))}
140 </div>
141
142 <ul className="mission-list" aria-label="Missions">
143 {visible.map(m => (
144 <li key={m.id}>
145 <strong>{m.name}</strong>
146 <span className="badge">{STATUS_LABELS[m.status]}</span>
147 <span className="crew-count">équipage : {m.crew}</span>
148 </li>
149 ))}
150 </ul>
151
152 <button className="refresh-btn" onClick={refetch}>Actualiser</button>
153 </section>
154 );
155}
156
157function CrewManager() {
158 const [crew, setCrew] = useState([
159 { id: 1, name: 'Commandant Nova', role: 'captain', experience: 15 },
160 { id: 2, name: 'Astro', role: 'pilot', experience: 6 },
161 { id: 3, name: 'Ingénieur Bolt', role: 'engineer', experience: 8 },
162 ]);
163 const [formData, setFormData] = useState({ name: '', role: 'pilot', experience: 0 });
164 const [errors, setErrors] = useState([]);
165 const { filtered, filter, setFilter } = useCrewFilter(crew);
166
167 const handleSubmit = (e) => {
168 e.preventDefault();
169 const validationErrors = validateCrewMember(formData);
170 if (validationErrors.length > 0) {
171 setErrors(validationErrors);
172 return;
173 }
174 setCrew(prev => [...prev, { ...formData, name: formData.name.trim(), id: Date.now() }]);
175 setFormData({ name: '', role: 'pilot', experience: 0 });
176 setErrors([]);
177 };
178
179 const handleRemove = (id) => {
180 setCrew(prev => prev.filter(m => m.id !== id));
181 };
182
183 return (
184 <section className="panel">
185 <h2>Gestion de l’équipage</h2>
186
187 <form className="crew-form" onSubmit={handleSubmit}>
188 <div className="field">
189 <label htmlFor="crew-name">Nom</label>
190 <input
191 id="crew-name"
192 value={formData.name}
193 onChange={e => setFormData(d => ({ ...d, name: e.target.value }))}
194 />
195 </div>
196 <div className="field">
197 <label htmlFor="crew-role">Rôle</label>
198 <select
199 id="crew-role"
200 value={formData.role}
201 onChange={e => setFormData(d => ({ ...d, role: e.target.value }))}
202 >
203 {Object.entries(ROLES).map(([value, label]) => (
204 <option key={value} value={value}>{label}</option>
205 ))}
206 </select>
207 </div>
208 <div className="field">
209 <label htmlFor="crew-exp">Expérience (années)</label>
210 <input
211 id="crew-exp"
212 type="number"
213 value={formData.experience}
214 onChange={e => setFormData(d => ({ ...d, experience: Number(e.target.value) }))}
215 />
216 </div>
217 <button type="submit">Ajouter un membre</button>
218 </form>
219
220 {errors.length > 0 && (
221 <div className="error-list" role="alert">
222 {errors.map(message => <p key={message}>{message}</p>)}
223 </div>
224 )}
225
226 <div className="filters">
227 {['all', ...Object.keys(ROLES)].map(r => (
228 <button key={r} aria-pressed={filter === r} onClick={() => setFilter(r)}>
229 {r === 'all' ? 'tous' : ROLES[r]}
230 </button>
231 ))}
232 </div>
233
234 <ul className="crew-list" aria-label="Équipage">
235 {filtered.map(m => (
236 <li key={m.id}>
237 <span>{m.name} ({ROLES[m.role]}, {yearsLabel(m.experience)})</span>
238 <button className="remove" aria-label={`Retirer : ${m.name}`} onClick={() => handleRemove(m.id)}>
239 Retirer
240 </button>
241 </li>
242 ))}
243 </ul>
244 </section>
245 );
246}
247
248function SystemStatus() {
249 const { systems, repairShields } = useSystemHealth(10000);
250 const critical = Object.keys(systems).filter(key => systems[key] < 30);
251
252 const getStatusColor = (level) => {
253 if (level > 80) return '#4caf50';
254 if (level > 50) return '#ff9800';
255 return '#f44336';
256 };
257
258 return (
259 <section className="panel">
260 <h2>État des systèmes</h2>
261 <p className="hint">Lecture des capteurs toutes les 10 s : les boucliers perdent 15 points.</p>
262 <div className="system-grid">
263 {Object.entries(systems).map(([key, value]) => (
264 <div key={key} className="system-item">
265 <span className="system-name">{SYSTEM_LABELS[key]}</span>
266 <div
267 className="progress-bar"
268 role="progressbar"
269 aria-label={SYSTEM_LABELS[key]}
270 aria-valuenow={value}
271 aria-valuemin={0}
272 aria-valuemax={100}
273 >
274 <div className="progress-fill" style={{ width: `${value}%`, background: getStatusColor(value) }} />
275 </div>
276 <span className="system-value">{value}%</span>
277 </div>
278 ))}
279 </div>
280 {critical.length > 0 && (
281 <div className="system-alert">
282 <p role="alert">Niveau critique : {critical.map(key => SYSTEM_LABELS[key]).join(', ')}</p>
283 <button onClick={repairShields}>Réparer les boucliers</button>
284 </div>
285 )}
286 </section>
287 );
288}
289
290export default function App() {
291 const [serverDown, setServerDown] = useState(false);
292 const loadMissions = useCallback(() => fetchMissions(serverDown), [serverDown]);
293
294 return (
295 <div className="app">
296 <h1>Centre de contrôle de mission cosmique</h1>
297 <p className="subtitle">
298 Projet principal : une application qui fonctionne et que vérifient les tests de la leçon.
299 L’aperçu n’exécute pas Jest, tu lances les tests dans ton propre projet.
300 </p>
301 <button className="sim-btn" onClick={() => setServerDown(d => !d)} aria-pressed={serverDown}>
302 {serverDown ? 'Rétablir le serveur' : 'Simuler une panne du serveur'}
303 </button>
304 {/* key : après le changement de serveur, le tableau repart de l’état de chargement */}
305 <MissionDashboard key={serverDown ? 'down' : 'up'} loadMissions={loadMissions} />
306 <CrewManager />
307 <SystemStatus />
308 </div>
309 );
310}Tu as repéré une erreur dans cette leçon ?