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.

CEL LEKCJI

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.
WPROWADZENIE

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.

POZNAJ SKŁADNIĘ

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ć.

src/index.ts — osobny przykład
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 }));
wynik w konsoli
[ ] 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.

src/index.ts — osobny przykład
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);
wynik w konsoli
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.

PRZYKŁAD Z OMÓWIENIEM

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.

src/model.ts — plik tego projektu
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;
}
src/menedzer.ts — plik tego projektu
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); }
}
src/index.ts — osobny przykład
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"); }
wynik w konsoli
Brak id: false
[x] 1. Trening
[ ] 2. Powtórka TypeScript
Odtworzono: 2
Usunięto: true
Pozostało: 1

Jak program dochodzi do wyniku

  1. model.ts jest granicą dla zewnętrznego tekstu: sprawdza pola, wartości oraz unikalność id.
  2. 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.
  3. Metody modyfikujące sprawdzają, czy znaleziono element. Nie wykonują splice z -1.
  4. lista zwraca kopie, a eksport serializuje wewnętrzną listę. Ponowny import przechodzi całą walidację.
  5. 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ą.

EKSPERYMENT

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”.

src/index.ts — osobny przykład
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");
wynik w konsoli
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.

CZĘSTE BŁĘDY

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.
ZADANIA

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.

ZAD 1Raport postępu★☆☆

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
src/index.ts — osobny przykład
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([]));
wynik w konsoli
1/2
0/0

Nie dzielimy przez liczbę misji, więc pusta lista ma sensowny raport.

ZAD 2Szukaj po tytule★★☆

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
src/index.ts — osobny przykład
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);
wynik w konsoli
Nauka TS
2

Każdy tekst zawiera pusty napis, więc ta reguła realizuje umówione zachowanie.

ZAD 3Procent ukończenia★★☆

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
src/index.ts — osobny przykład
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]));
wynik w konsoli
0 33 100

Zaokrąglenie jest regułą prezentacji wyniku.

ZAD 4Rozszerzenie: priorytety★★★

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
src/index.ts — osobny przykład
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(", "));
wynik w konsoli
Konkurs, Trening

Samo dopisanie typu nie aktualizuje importu. Przy zmianie modelu przejdź całą drogę danych: utworzenie, zapis, odczyt, walidacja i test.

PODSUMOWANIE

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.