Projekt końcowy — menedżer misji w konsoli
Połącz poznane elementy w działający program: dodawaj misje, kończ je, usuwaj i przenoś listę przez JSON. Sprawdź reguły także na błędnych danych.
Po tej lekcji potrafisz
- Zbudujesz program podzielony na model, logikę i uruchomienie.
- Dodasz, zakończysz i usuniesz zadanie z kontrolą id.
- Sprawdzisz eksport, ponowny import i ochronę wewnętrznego stanu.
Misje na szkolny tydzień
Drużyna planuje trening, prezentację i konkurs. Potrzebuje listy, którą da się zapisać jako tekst i później odtworzyć. Projekt wykonamy etapami: najpierw model i operacje, potem import/eksport, na końcu próby błędnych danych. Interfejsem będzie seria wywołań i raport w konsoli.
Małe kroki, konkretne efekty
Najpierw reguły i kryteria odbioru
Zadanie ma dodatnie, unikalne id, niepusty tytuł i flagę gotowe. Dodawanie tworzy nowe id, zakończenie i usuwanie zwracają false przy braku zadania. Lista na zewnątrz jest kopią. To konkretne wymagania, które można sprawdzić.
interface Zadanie { id: number; tytul: string; gotowe: boolean }
function wiersz(z: Zadanie): string {
return (z.gotowe ? "[x] " : "[ ] ") + z.id + ". " + z.tytul;
}
console.log(wiersz({ id: 1, tytul: "Trening", gotowe: false }));
console.log(wiersz({ id: 2, tytul: "Powtórka", gotowe: true }));
[ ] 1. Trening
[x] 2. Powtórka
Etap 1: przepisz model i funkcję raportu. Etap 2: zaimplementuj klasę z metodami. Etap 3: dodaj walidowany import. Etap 4: przeprowadź testy i własne rozszerzenie. W plikach do pobrania znajdziesz gotowy projekt do porównania.
Zwracaj kopie, jeśli chcesz chronić stan
Samo private nie wystarcza, jeśli metoda oddaje wewnętrzną tablicę. Wywołujący mógłby zmienić jej elementy. Tu kopiujemy tablicę i każdy płaski obiekt zadania.
const wewnetrzne = [{ id: 1, tytul: "Trening", gotowe: false }];
const widok = wewnetrzne.map(z => ({ ...z }));
const pierwszy = widok[0];
if (pierwszy) { pierwszy.tytul = "Podmieniono"; }
console.log(wewnetrzne[0]?.tytul);
Trening
W naszych zadaniach wszystkie pola są proste, więc taki poziom kopiowania wystarcza. Przy zagnieżdżonych obiektach należałoby przeanalizować kolejne poziomy.
Kompletny menedżer misji
Utwórz trzy poniższe pliki w src i użyj npm start. Konstruktor dostaje dane wewnętrzne lub już sprawdzone przez odczytaj. Eksport tworzy tekst JSON, a nie zapis na dysku — tekst możesz skopiować do kolejnego uruchomienia.
export interface Zadanie { id: number; tytul: string; gotowe: boolean }
export function czyZadanie(x: unknown): x is Zadanie {
if (typeof x !== "object" || x === null) { return false; }
return "id" in x && typeof x.id === "number" && Number.isSafeInteger(x.id) && x.id > 0
&& "tytul" in x && typeof x.tytul === "string" && x.tytul.trim().length > 0
&& "gotowe" in x && typeof x.gotowe === "boolean";
}
export function odczytaj(tekst: string): Zadanie[] {
const dane: unknown = JSON.parse(tekst);
if (!Array.isArray(dane) || !dane.every(czyZadanie)) { throw new Error("Niepoprawne zadania"); }
const ids = new Set<number>();
const wynik: Zadanie[] = [];
for (const z of dane) {
if (ids.has(z.id)) { throw new Error("Powtórzone id"); }
ids.add(z.id);
wynik.push({ id: z.id, tytul: z.tytul.trim(), gotowe: z.gotowe });
}
return wynik;
}
import type { Zadanie } from "./model.js";
export class Menedzer {
private zadania: Zadanie[];
constructor(zadania: readonly Zadanie[] = []) { this.zadania = zadania.map(z => ({ ...z })); }
dodaj(tytul: string): Zadanie {
const czysty = tytul.trim();
if (!czysty) { throw new Error("Tytuł nie może być pusty"); }
const id = this.zadania.reduce((max, z) => Math.max(max, z.id), 0) + 1;
if (!Number.isSafeInteger(id)) { throw new Error("Brak dostępnych identyfikatorów"); }
const nowe = { id, tytul: czysty, gotowe: false };
this.zadania.push(nowe);
return { ...nowe };
}
zakoncz(id: number): boolean {
const z = this.zadania.find(z => z.id === id);
if (!z) { return false; }
z.gotowe = true;
return true;
}
usun(id: number): boolean {
const i = this.zadania.findIndex(z => z.id === id);
if (i === -1) { return false; }
this.zadania.splice(i, 1);
return true;
}
lista(): Zadanie[] { return this.zadania.map(z => ({ ...z })); }
eksport(): string { return JSON.stringify(this.zadania); }
}
import { odczytaj } from "./model.js";
import { Menedzer } from "./menedzer.js";
function main(): void {
const m = new Menedzer();
const trening = m.dodaj("Trening");
m.dodaj("Powtórka TypeScript");
m.zakoncz(trening.id);
console.log("Brak id: " + m.zakoncz(999));
for (const z of m.lista()) {
console.log((z.gotowe ? "[x] " : "[ ] ") + z.id + ". " + z.tytul);
}
const zapis = m.eksport();
const odtworzony = new Menedzer(odczytaj(zapis));
console.log("Odtworzono: " + odtworzony.lista().length);
console.log("Usunięto: " + odtworzony.usun(trening.id));
console.log("Pozostało: " + odtworzony.lista().length);
}
try { main(); }
catch (e) { console.log(e instanceof Error ? e.message : "Błąd programu"); }
Brak id: false
[x] 1. Trening
[ ] 2. Powtórka TypeScript
Odtworzono: 2
Usunięto: true
Pozostało: 1
Jak program dochodzi do wyniku
- model.ts jest granicą dla zewnętrznego tekstu: sprawdza pola, wartości oraz unikalność id.
- Menedzer tworzy id jako największe obecne id + 1. Po usunięciu ostatniego id może je później użyć ponownie — gwarantujemy unikalność bieżącej listy, nie historii.
- Metody modyfikujące sprawdzają, czy znaleziono element. Nie wykonują splice z -1.
- lista zwraca kopie, a eksport serializuje wewnętrzną listę. Ponowny import przechodzi całą walidację.
- Punkt wejścia wykonuje scenariusz i odpowiada za komunikaty. Logika nie zależy od HTML ani konsolowego menu.
Zanim przejdziesz dalej
Czy samo new Menedzer(JSON.parse(tekst)) jest bezpiecznym importem?
Sprawdź swoje rozumowanie
Nie. JSON.parse zwraca dane bez kontroli modelu. Użyj new Menedzer(odczytaj(tekst)), bo konstruktor zakłada dane już zgodne z umową.
Zmień kod i zobacz, co się stanie
Sprawdzian działania: próby, które powinny nie przejść
W swoim projekcie sprawdź pusty tytuł, nieznane id, powtórzony import oraz zmianę obiektu pobranego z listy. Test ma porównać wynik z oczekiwaniem, a nie jedynie wypisać „uruchomiono”.
import { strict as assert } from "node:assert";
function tytul(tekst: string): string {
const wynik = tekst.trim();
if (!wynik) { throw new Error("Pusty tytuł"); }
return wynik;
}
assert.equal(tytul(" Trening "), "Trening");
assert.throws(() => tytul(" "), /Pusty tytuł/);
console.log("2 sprawdzenia zakończone poprawnie");
2 sprawdzenia zakończone poprawnie
node:assert jest wbudowany w Node.js. W gotowym projekcie plik test.ts sprawdza operacje Menedzera, walidację, kopie i ponowny import. Jeśli reguła przestanie działać, test zgłosi błąd i proces zakończy się niepowodzeniem.
Gdy wynik zaskakuje
| Pomyłka | Co się dzieje i jak to poprawić |
|---|---|
| Numer id z długości listy + 1 | Po usunięciu elementu może powtórzyć istniejące id. Szukamy maksimum. |
| Bezpośrednie zwracanie prywatnej tablicy | Pozwala na niekontrolowane zmiany z zewnątrz. |
| Eksport nazwany zapisem pliku | JSON.stringify tworzy tekst. Trwały zapis do pliku to osobna operacja i nie jest wykonywany automatycznie. |
Teraz Twoja kolej
Rozwiązanie jest jedną z możliwości. Zanim je odkryjesz, uruchom własną wersję dla podanych danych i przynajmniej jednego przypadku brzegowego.
Napisz funkcję liczącą ukończone zadania i wypisującą „1/2” dla dwóch misji, z których jedna jest gotowa. Dla pustej listy oczekujemy „0/0”.
Mała podpowiedź
filter i length.
Przykładowe rozwiązanie z wyjaśnieniem
type Misja = { gotowe: boolean };
function postep(misje: Misja[]): string { return misje.filter(m => m.gotowe).length + "/" + misje.length; }
console.log(postep([{ gotowe: true }, { gotowe: false }]));
console.log(postep([]));
1/2
0/0
Nie dzielimy przez liczbę misji, więc pusta lista ma sensowny raport.
Przygotuj niezależną funkcję wyszukiwania po fragmencie tytułu bez rozróżniania wielkości liter. „ts” ma znaleźć „Nauka TS”. Puste zapytanie zwraca całą listę.
Mała podpowiedź
toLowerCase i includes; następnie możesz dodać metodę w klasie.
Przykładowe rozwiązanie z wyjaśnieniem
function szukaj(tytuly: string[], pytanie: string): string[] {
const q = pytanie.trim().toLowerCase();
return tytuly.filter(t => t.toLowerCase().includes(q));
}
console.log(szukaj(["Nauka TS", "Trening"], "ts").join(","));
console.log(szukaj(["Nauka TS", "Trening"], "").length);
Nauka TS
2
Każdy tekst zawiera pusty napis, więc ta reguła realizuje umówione zachowanie.
Napisz funkcję zwracającą całkowity procent ukończenia. Dla pustej listy 0, dla jednej ukończonej z trzech 33, dla wszystkich 100.
Mała podpowiedź
Najpierw przypadek pusty, potem Math.round.
Przykładowe rozwiązanie z wyjaśnieniem
function procent(gotowe: boolean[]): number {
if (gotowe.length === 0) { return 0; }
return Math.round(gotowe.filter(x => x).length / gotowe.length * 100);
}
console.log(procent([]), procent([true, false, false]), procent([true, true]));
0 33 100
Zaokrąglenie jest regułą prezentacji wyniku.
Zaprojektuj typ priorytetu „niski” | „normalny” | „pilny” i uporządkuj misje od pilnych do niskich. Jako dalszy etap dodaj to pole do modelu, walidatora i testów projektu.
Mała podpowiedź
Record mapuje priorytet na liczbę sortowania.
Przykładowe rozwiązanie z wyjaśnieniem
type Priorytet = "niski" | "normalny" | "pilny";
type Misja = { tytul: string; priorytet: Priorytet };
const waga: Record<Priorytet, number> = { niski: 1, normalny: 2, pilny: 3 };
const misje: Misja[] = [{ tytul: "Trening", priorytet: "niski" }, { tytul: "Konkurs", priorytet: "pilny" }];
const kolejnosc = [...misje].sort((a, b) => waga[b.priorytet] - waga[a.priorytet]);
console.log(kolejnosc.map(m => m.tytul).join(", "));
Konkurs, Trening
Samo dopisanie typu nie aktualizuje importu. Przy zmianie modelu przejdź całą drogę danych: utworzenie, zapis, odczyt, walidacja i test.
Co zabierasz do następnej lekcji
- Podział odpowiedzialności ułatwia zmianę i sprawdzanie programu.
- Granica importu wymaga walidacji, a wnętrze klasy jasnych reguł.
- Sprawdzaj sukcesy, błędy, przypadki brzegowe i ochronę stanu.