Kurs JavaScript i React · Moduł 13: Testowanie React
Projekt Główny: Kompletny Zestaw Testów - Kosmiczne Centrum Kontroli Misji
W tej lekcji8
Nadszedł czas na podsumowanie wszystkiego, czego nauczyliśmy się w tym module! Komandor Nova szykuje flotę do startu, a centrum kontroli misji to kilka paneli, które muszą działać bez zarzutu. Każda zmiana w kodzie może po cichu zepsuć formularz załogi albo alarm osłon, a nikt nie przeklika całej aplikacji przed każdym startem. Twoim zadaniem jest zabezpieczyć tę aplikację kompletnym zestawem testów, czyli połączyć w jednym projekcie wszystkie techniki z tego modułu.
Opis aplikacji
Działającą wersję aplikacji znajdziesz w podglądzie na końcu lekcji. Centrum Kontroli Misji składa się z następujących części:
1. MissionDashboard - panel misji
- Pobiera listę misji przez funkcję
loadMissions, którą dostaje w propsach - Pokazuje stan ładowania, błąd z przyciskiem ponowienia i gotowe dane
- Filtruje misje po statusie
- Wyświetla podsumowanie: liczbę wszystkich i aktywnych misji
2. CrewManager - zarządzanie załogą
- Formularz dodawania członków załogi z polami Imię, Rola i Doświadczenie (lata)
- Walidacja danych funkcją
validateCrewMember(imię, rola, doświadczenie) - Lista członków z przyciskami usuwania i filtrem ról
3. SystemStatus - stan systemów
- Pokazuje poziomy systemów statku jako paski z
role="progressbar" - Co 10 sekund odczytuje czujniki: osłony słabną wtedy o 15 punktów
- Ogłasza alarm z
role="alert", gdy któryś poziom spadnie poniżej 30%
4. Własne hooki
useMissions- pobieranie misji, stan ładowania i błędu, ponowienieuseSystemHealth- odczyt systemów co zadany czasuseCrewFilter- filtrowanie załogi po roli
Jak pracować z tym projektem
Podgląd pokazuje działającą aplikację, ale nie uruchamia Jesta. Testy z tej lekcji są napisane dla dokładnie tych komponentów, więc przenieś kod z podglądu do swojego projektu, rozdziel go na pliki według struktury poniżej i uruchom npm test. Projekt skonfiguruj tak jak w pierwszej lekcji modułu: Jest ze środowiskiem jsdom, transformacja JSX i @testing-library/jest-dom w pliku setup.
Zadania do wykonania
Twój zestaw testów powinien obejmować wszystkie poziomy, które poznałeś w module:
- Testy jednostkowe - funkcje pomocnicze, walidacja
- Testy komponentów - renderowanie, interakcje, stany
- Testy asynchroniczne - pobieranie danych, ładowanie, błędy
- Testy hooków - własne hooki z
renderHook - Mockowanie API - atrapy
jest.fn(),jest.mock, mockowaniefetch - Snapshoty - kluczowe komponenty prezentacyjne
Struktura testów
Każdy komponent, hook i plik z funkcjami pomocniczymi dostaje własny plik testów, więc gdy test padnie, od razu wiesz, gdzie szukać. Jest znajdzie je sam, bo leżą w folderze __tests__ i mają końcówkę .test.js:
1__tests__/
2 MissionDashboard.test.js -- testy panelu misji
3 CrewManager.test.js -- testy zarządzania załogą
4 SystemStatus.test.js -- testy stanu systemów
5 hooks/
6 useMissions.test.js -- testy hooka misji
7 useCrewFilter.test.js -- testy hooka filtrowania załogi
8 utils/
9 validators.test.js -- testy funkcji walidacjiFolder __tests__ leży w src obok komponentów, a hooki i funkcje pomocnicze trzymasz w src/hooks i src/utils. Każdy z tych plików eksportuje to, co testujesz, a importy w przykładach poniżej zakładają właśnie taki układ.
Kluczowe wzorce do zastosowania
Poniższe wzorce pokrywają cały zestaw: od czystej funkcji, przez hook i komponent, po asynchroniczność, czas i błędy. Każdy przykład działa z kodem z podglądu. Nazwy testów zostają po angielsku, jak w całym module.
Wzorzec: test czystej funkcji
Walidacja nie potrzebuje Reacta: dostaje obiekt i zwraca listę błędów, więc testujesz ją tak jak funkcje z lekcji o 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(['Imię musi mieć co najmniej 2 znaki', 'Nieznana rola']);
7});toEqual porównuje zawartość tablicy, a nie referencję, więc test sprawdza dokładnie te dwa komunikaty, które zobaczy użytkownik. To najtańszy i najszybszy test w całym zestawie, podstawa piramidy testów.
Wzorzec: test hooka
Hook useCrewFilter uruchamiasz przez renderHook, a zmianę filtra owijasz w act, jak w lekcji o hookach:
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: 'Komandor 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});Wynik czytasz z result.current dopiero po act, więc widzisz stan po zmianie filtra: na liście zostaje tylko pilot. Hook nie wie nic o formularzu ani o przyciskach, dlatego jego test jest krótki i odporny na zmiany w wyglądzie.
Wzorzec: Arrange-Act-Assert
Każdy test komponentu ma trzy części. Najpierw przygotowujesz scenę (Arrange), potem wykonujesz akcję jak użytkownik (Act), a na końcu sprawdzasz wynik (Assert). Komentarze w teście pokazują ten podział:
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: przygotuj
7 const user = userEvent.setup();
8 render(<CrewManager />);
9
10 // Act: wykonaj
11 await user.type(screen.getByLabelText('Imię'), 'Ra');
12 await user.selectOptions(screen.getByLabelText('Rola'), 'kapitan');
13 await user.click(screen.getByRole('button', { name: 'Dodaj członka załogi' }));
14
15 // Assert: sprawdź
16 expect(screen.getByText('Ra (kapitan, 0 lat)')).toBeInTheDocument();
17});getByLabelText znajduje pola, bo etykiety są połączone z nimi przez htmlFor i id. Asercja szuka całego wpisu na liście, a nie samego słowa "kapitan", bo to słowo jest też opcją w polu wyboru i test przeszedłby nawet wtedy, gdyby dodawanie nie działało. Dopisz drugi test dla ścieżki błędu: imię X powinno pokazać komunikat w elemencie z role="alert".
Wzorzec: izolacja testów
Testy w jednym pliku nie mogą od siebie zależeć. Atrapa współdzielona przez kilka testów pamięta wywołania z poprzednich, więc bez sprzątania licznik z pierwszego testu zepsułby asercję w drugim. Dlatego beforeEach czyści historię przed każdym testem:
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 // Każdy test zaczyna z pustą historią wywołań mocka
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('Ładowanie misji...');
18 expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
19 expect(loadMissions).toHaveBeenCalledTimes(1);
20});Panel dostaje funkcję loadMissions w propsach, więc w teście podajesz atrapę jest.fn() zamiast prawdziwego API, a w podglądzie ten sam prop dostaje symulowany serwer. Najpierw widać stan ładowania z role="status", a findByText czeka, aż pojawią się dane. Drugi test sprawdza ścieżkę błędu i ponowienie:
1test('shows an error and retries', async () => {
2 const user = userEvent.setup();
3 loadMissions
4 .mockRejectedValueOnce(new Error('Brak połączenia z serwerem misji'))
5 .mockResolvedValueOnce(missions);
6 render(<MissionDashboard loadMissions={loadMissions} />);
7
8 expect(await screen.findByRole('alert')).toHaveTextContent('Brak połączenia');
9 await user.click(screen.getByRole('button', { name: 'Spróbuj ponownie' }));
10
11 expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
12 expect(loadMissions).toHaveBeenCalledTimes(2);
13});Dwa wywołania Once ustawiają kolejne odpowiedzi: najpierw awaria, potem dane. Bez jest.clearAllMocks() w beforeEach ten test policzyłby także wywołanie z poprzedniego testu i zobaczyłby 3 zamiast 2. Gdy komponent sam woła fetch, dopisz w beforeEach także global.fetch = jest.fn(), jak w lekcji o mockowaniu.
Wzorzec: czas pod kontrolą
Alarm osłon pojawia się dopiero po dwóch odczytach czujników, czyli po 20 sekundach. Fake timery przewijają ten czas w ułamku sekundy:
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 // Dwa odczyty czujników: 55% -> 40% -> 25%
11 act(() => {
12 jest.advanceTimersByTime(20000);
13 });
14
15 expect(screen.getByRole('progressbar', { name: 'Osłony' })).toHaveAttribute('aria-valuenow', '25');
16 expect(screen.getByRole('alert')).toHaveTextContent('Osłony');
17 jest.useRealTimers();
18});queryByRole na początku potwierdza, że alarmu jeszcze nie ma, bo getByRole rzuciłby w tym miejscu błąd. Przewinięcie zegara zmienia stan poza narzędziami RTL, dlatego stoi w act. Pasek znajdujesz po roli progressbar i nazwie z aria-label, a jego wartość odczytujesz z aria-valuenow.
Wzorzec: testowanie Error Boundary
Error Boundary to komponent, który łapie błąd rzucony podczas renderowania dzieci i zamiast pustego ekranu pokazuje komunikat zastępczy. React nie ma do tego hooka, więc granicę piszesz jako komponent klasowy z metodą static getDerivedStateFromError albo bierzesz gotową z biblioteki 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 ustawia hasError, a render pokazuje wtedy element z propsa fallback. Test podsuwa granicy komponent, który zawsze rzuca błąd:
1test('shows error boundary on crash', () => {
2 const ThrowError = () => {
3 throw new Error('Kaboom!');
4 };
5
6 // React 19 zgłasza błąd złapany przez granicę przez console.error
7 const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
8
9 render(
10 <ErrorBoundary fallback={<p>Coś poszło nie tak</p>}>
11 <ThrowError />
12 </ErrorBoundary>
13 );
14
15 expect(screen.getByText('Coś poszło nie tak')).toBeInTheDocument();
16 spy.mockRestore();
17});W React 19 błąd złapany przez granicę trafia do console.error, więc bez szpiega test przejdzie, ale zaśmieci wynik czerwonym stosem wywołań. mockRestore() na końcu oddaje konsoli jej prawdziwą funkcję, żeby kolejne testy znowu widziały ostrzeżenia.
Dobre praktyki testowe
- Testuj zachowanie, nie implementację - sprawdzaj, co widzi użytkownik
- Jeden koncept na test - każdy test sprawdza jedną rzecz
- Czytelne nazwy testów -
it('displays error when mission not found') - DRY w testach z umiarem - powtórzenie w testach jest lepsze niż zbyt skomplikowana abstrakcja
- Testuj ścieżki sukcesu i błędu - scenariusz, w którym wszystko się udaje, i przypadki brzegowe
- Nie testuj frameworka - nie sprawdzaj, czy
useStatedziała, testuj swoją logikę
Co oddajesz mentorowi
Po tej lekcji czeka Cię projekt dla mentora. Zbuduj własną aplikację React albo rozbuduj Centrum Kontroli Misji i przygotuj do niej zestaw testów, który obejmuje:
- panel z danymi z API (w testach atrapa zamiast prawdziwego serwera) z obsługą ładowania i błędu
- formularz z walidacją, sprawdzony przez
userEventi komunikaty zrole="alert" - co najmniej jeden własny hook przetestowany przez
renderHook - testy jednostkowe, testy integracyjne kilku komponentów i snapshot prostego komponentu prezentacyjnego
- ścieżki sukcesu i przypadki brzegowe
Wypchnij projekt z testami na GitHub i prześlij link do repozytorium. Mentor uruchomi npm test, więc sprawdź, że wszystkie testy przechodzą na świeżej kopii projektu.
Sprawdź podgląd
Poniżej znajdziesz działające Centrum Kontroli Misji. Przeklikaj je i porównaj z testami z lekcji:
- kliknij Symuluj awarię serwera: panel pokaże błąd i przycisk Spróbuj ponownie, tak jak w teście ścieżki błędu, a po kliknięciu Przywróć serwer pobierze misje od nowa
- dodaj członka załogi z jednoliterowym imieniem i zobacz komunikaty walidacji
- poczekaj 20 sekund, aż osłony spadną poniżej 30% i pojawi się alarm
Moja rada: zanim napiszesz test, przeklikaj scenariusz w podglądzie i zapisz go jako trzy kroki: Arrange, Act i Assert. Wtedy test pisze się prawie sam. Pamiętaj: dobry zestaw testów to kontrola przedstartowa, która przy każdej zmianie w kodzie sprawdza cały statek, zanim polecisz w prawdziwy kosmos.
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect, useCallback } from 'react';
2
3// ===== Symulowane API =====
4// Podgląd nie ma serwera, więc odpowiedź udaje Promise z setTimeout.
5// W testach z lekcji MissionDashboard dostaje zamiast tego 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('Brak połączenia z serwerem misji'));
17 else resolve(MISSIONS);
18 }, 800);
19 });
20}
21
22// ===== Funkcje pomocnicze =====
23const ROLES = { captain: 'kapitan', pilot: 'pilot', engineer: 'inżynier', scientist: 'naukowiec' };
24const FILTER_LABELS = { all: 'wszystkie', active: 'aktywne', completed: 'zakończone', planned: 'planowane' };
25const STATUS_LABELS = { active: 'aktywna', completed: 'zakończona', planned: 'planowana' };
26const SYSTEM_LABELS = { engine: 'Silnik', shields: 'Osłony', navigation: 'Nawigacja', lifeSupport: 'Podtrzymywanie życia' };
27
28function validateCrewMember(member) {
29 const errors = [];
30 if (!member.name || member.name.trim().length < 2) {
31 errors.push('Imię musi mieć co najmniej 2 znaki');
32 }
33 if (!Object.keys(ROLES).includes(member.role)) {
34 errors.push('Nieznana rola');
35 }
36 if (member.experience < 0 || member.experience > 50) {
37 errors.push('Doświadczenie musi wynosić od 0 do 50 lat');
38 }
39 return errors;
40}
41
42function yearsLabel(n) {
43 if (n === 1) return '1 rok';
44 const lastDigit = n % 10;
45 const lastTwo = n % 100;
46 if (lastDigit >= 2 && lastDigit <= 4 && (lastTwo < 12 || lastTwo > 14)) return `${n} lata`;
47 return `${n} lat`;
48}
49
50// ===== Własne hooki =====
51function useMissions(loadMissions) {
52 const [missions, setMissions] = useState([]);
53 const [loading, setLoading] = useState(true);
54 const [error, setError] = useState(null);
55 const [attempt, setAttempt] = useState(0);
56
57 useEffect(() => {
58 let ignore = false;
59 loadMissions()
60 .then(data => {
61 if (!ignore) setMissions(data);
62 })
63 .catch(err => {
64 if (!ignore) setError(err.message);
65 })
66 .finally(() => {
67 if (!ignore) setLoading(false);
68 });
69 return () => {
70 ignore = true;
71 };
72 }, [loadMissions, attempt]);
73
74 // Ponowienie: od razu pokazujemy ładowanie i pobieramy jeszcze raz
75 const refetch = useCallback(() => {
76 setLoading(true);
77 setError(null);
78 setAttempt(a => a + 1);
79 }, []);
80 return { missions, loading, error, refetch };
81}
82
83function useCrewFilter(crew) {
84 const [filter, setFilter] = useState('all');
85 const filtered = filter === 'all' ? crew : crew.filter(member => member.role === filter);
86 return { filtered, filter, setFilter };
87}
88
89function useSystemHealth(intervalMs = 10000) {
90 const [systems, setSystems] = useState({ engine: 98, shields: 55, navigation: 100, lifeSupport: 92 });
91
92 useEffect(() => {
93 // Co odczyt czujników osłony słabną o 15 punktów
94 const id = setInterval(() => {
95 setSystems(s => ({ ...s, shields: Math.max(s.shields - 15, 0) }));
96 }, intervalMs);
97 return () => clearInterval(id);
98 }, [intervalMs]);
99
100 const repairShields = () => setSystems(s => ({ ...s, shields: 100 }));
101 return { systems, repairShields };
102}
103
104// ===== Komponenty =====
105function MissionDashboard({ loadMissions }) {
106 const { missions, loading, error, refetch } = useMissions(loadMissions);
107 const [statusFilter, setStatusFilter] = useState('all');
108
109 if (loading) {
110 return (
111 <section className="panel">
112 <h2>Panel misji</h2>
113 <p className="loading" role="status">Ładowanie misji...</p>
114 </section>
115 );
116 }
117
118 if (error) {
119 return (
120 <section className="panel">
121 <h2>Panel misji</h2>
122 <p className="error" role="alert">Błąd: {error}</p>
123 <button className="refresh-btn" onClick={refetch}>Spróbuj ponownie</button>
124 </section>
125 );
126 }
127
128 const visible = statusFilter === 'all' ? missions : missions.filter(m => m.status === statusFilter);
129
130 return (
131 <section className="panel">
132 <h2>Panel misji</h2>
133 <div className="stats">
134 <div className="stat">Wszystkie: {missions.length}</div>
135 <div className="stat">Aktywne: {missions.filter(m => m.status === 'active').length}</div>
136 </div>
137
138 <div className="filters">
139 {Object.keys(FILTER_LABELS).map(s => (
140 <button key={s} aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}>
141 {FILTER_LABELS[s]}
142 </button>
143 ))}
144 </div>
145
146 <ul className="mission-list" aria-label="Misje">
147 {visible.map(m => (
148 <li key={m.id}>
149 <strong>{m.name}</strong>
150 <span className="badge">{STATUS_LABELS[m.status]}</span>
151 <span className="crew-count">załoga: {m.crew}</span>
152 </li>
153 ))}
154 </ul>
155
156 <button className="refresh-btn" onClick={refetch}>Odśwież</button>
157 </section>
158 );
159}
160
161function CrewManager() {
162 const [crew, setCrew] = useState([
163 { id: 1, name: 'Komandor Nova', role: 'captain', experience: 15 },
164 { id: 2, name: 'Astro', role: 'pilot', experience: 6 },
165 { id: 3, name: 'Inżynier Bolt', role: 'engineer', experience: 8 },
166 ]);
167 const [formData, setFormData] = useState({ name: '', role: 'pilot', experience: 0 });
168 const [errors, setErrors] = useState([]);
169 const { filtered, filter, setFilter } = useCrewFilter(crew);
170
171 const handleSubmit = (e) => {
172 e.preventDefault();
173 const validationErrors = validateCrewMember(formData);
174 if (validationErrors.length > 0) {
175 setErrors(validationErrors);
176 return;
177 }
178 setCrew(prev => [...prev, { ...formData, name: formData.name.trim(), id: Date.now() }]);
179 setFormData({ name: '', role: 'pilot', experience: 0 });
180 setErrors([]);
181 };
182
183 const handleRemove = (id) => {
184 setCrew(prev => prev.filter(m => m.id !== id));
185 };
186
187 return (
188 <section className="panel">
189 <h2>Zarządzanie załogą</h2>
190
191 <form className="crew-form" onSubmit={handleSubmit}>
192 <div className="field">
193 <label htmlFor="crew-name">Imię</label>
194 <input
195 id="crew-name"
196 value={formData.name}
197 onChange={e => setFormData(d => ({ ...d, name: e.target.value }))}
198 />
199 </div>
200 <div className="field">
201 <label htmlFor="crew-role">Rola</label>
202 <select
203 id="crew-role"
204 value={formData.role}
205 onChange={e => setFormData(d => ({ ...d, role: e.target.value }))}
206 >
207 {Object.entries(ROLES).map(([value, label]) => (
208 <option key={value} value={value}>{label}</option>
209 ))}
210 </select>
211 </div>
212 <div className="field">
213 <label htmlFor="crew-exp">Doświadczenie (lata)</label>
214 <input
215 id="crew-exp"
216 type="number"
217 value={formData.experience}
218 onChange={e => setFormData(d => ({ ...d, experience: Number(e.target.value) }))}
219 />
220 </div>
221 <button type="submit">Dodaj członka załogi</button>
222 </form>
223
224 {errors.length > 0 && (
225 <div className="error-list" role="alert">
226 {errors.map(message => <p key={message}>{message}</p>)}
227 </div>
228 )}
229
230 <div className="filters">
231 {['all', ...Object.keys(ROLES)].map(r => (
232 <button key={r} aria-pressed={filter === r} onClick={() => setFilter(r)}>
233 {r === 'all' ? 'wszyscy' : ROLES[r]}
234 </button>
235 ))}
236 </div>
237
238 <ul className="crew-list" aria-label="Załoga">
239 {filtered.map(m => (
240 <li key={m.id}>
241 <span>{m.name} ({ROLES[m.role]}, {yearsLabel(m.experience)})</span>
242 <button className="remove" aria-label={`Usuń: ${m.name}`} onClick={() => handleRemove(m.id)}>
243 Usuń
244 </button>
245 </li>
246 ))}
247 </ul>
248 </section>
249 );
250}
251
252function SystemStatus() {
253 const { systems, repairShields } = useSystemHealth(10000);
254 const critical = Object.keys(systems).filter(key => systems[key] < 30);
255
256 const getStatusColor = (level) => {
257 if (level > 80) return '#4caf50';
258 if (level > 50) return '#ff9800';
259 return '#f44336';
260 };
261
262 return (
263 <section className="panel">
264 <h2>Stan systemów</h2>
265 <p className="hint">Odczyt czujników co 10 s: osłony słabną o 15 punktów.</p>
266 <div className="system-grid">
267 {Object.entries(systems).map(([key, value]) => (
268 <div key={key} className="system-item">
269 <span className="system-name">{SYSTEM_LABELS[key]}</span>
270 <div
271 className="progress-bar"
272 role="progressbar"
273 aria-label={SYSTEM_LABELS[key]}
274 aria-valuenow={value}
275 aria-valuemin={0}
276 aria-valuemax={100}
277 >
278 <div className="progress-fill" style={{ width: `${value}%`, background: getStatusColor(value) }} />
279 </div>
280 <span className="system-value">{value}%</span>
281 </div>
282 ))}
283 </div>
284 {critical.length > 0 && (
285 <div className="system-alert">
286 <p role="alert">Krytyczny poziom: {critical.map(key => SYSTEM_LABELS[key]).join(', ')}</p>
287 <button onClick={repairShields}>Napraw osłony</button>
288 </div>
289 )}
290 </section>
291 );
292}
293
294export default function App() {
295 const [serverDown, setServerDown] = useState(false);
296 const loadMissions = useCallback(() => fetchMissions(serverDown), [serverDown]);
297
298 return (
299 <div className="app">
300 <h1>Kosmiczne Centrum Kontroli Misji</h1>
301 <p className="subtitle">
302 Projekt główny: działająca aplikacja, którą sprawdzają testy z lekcji.
303 Podgląd nie uruchamia Jesta, testy uruchamiasz w swoim projekcie.
304 </p>
305 <button className="sim-btn" onClick={() => setServerDown(d => !d)} aria-pressed={serverDown}>
306 {serverDown ? 'Przywróć serwer' : 'Symuluj awarię serwera'}
307 </button>
308 {/* key: po przełączeniu serwera panel startuje od nowa, od stanu ładowania */}
309 <MissionDashboard key={serverDown ? 'down' : 'up'} loadMissions={loadMissions} />
310 <CrewManager />
311 <SystemStatus />
312 </div>
313 );
314}Widzisz błąd w tej lekcji?