Kurs JavaScript i React · Moduł 13: Testowanie React

Projekt Główny: Kompletny Zestaw Testów - Kosmiczne Centrum Kontroli Misji

9 min czytania
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, ponowienie
  • useSystemHealth - odczyt systemów co zadany czas
  • useCrewFilter - 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:

  1. Testy jednostkowe - funkcje pomocnicze, walidacja
  2. Testy komponentów - renderowanie, interakcje, stany
  3. Testy asynchroniczne - pobieranie danych, ładowanie, błędy
  4. Testy hooków - własne hooki z renderHook
  5. Mockowanie API - atrapy jest.fn(), jest.mock, mockowanie fetch
  6. 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 walidacji

Folder __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

  1. Testuj zachowanie, nie implementację - sprawdzaj, co widzi użytkownik
  2. Jeden koncept na test - każdy test sprawdza jedną rzecz
  3. Czytelne nazwy testów - it('displays error when mission not found')
  4. DRY w testach z umiarem - powtórzenie w testach jest lepsze niż zbyt skomplikowana abstrakcja
  5. Testuj ścieżki sukcesu i błędu - scenariusz, w którym wszystko się udaje, i przypadki brzegowe
  6. Nie testuj frameworka - nie sprawdzaj, czy useState dział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 userEvent i komunikaty z role="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?

Przydatne artykuły