Grafikus C# – Windows Forms tanfolyam › 12.9 lecke Windows Forms · 12.9 lecke · OOP A 12.6–12.8 leckékben szétválasztottad a modellt, a logikát és a tárolást. Most összefoglaljuk és rendszerbe foglaljuk: az alkalmazás rétegekből áll, a rétegek közötti függőségek iránya meghatározott, és a kód kiszámítható helyen él. Ez az architektúra: nem bonyolult elméletek, hanem néhány egyszerű szabály arról, hogy „mi hova való”, és „ki kit ismerhet”. Megtanulod a rétegeket, a mappaszerkezetet és névtereket, a többprojektes megoldás előnyét, egy egyszerű nézet–presenter mintát, és azt, hogyan követed végig egy felhasználói művelet útját az alkalmazásban.Egyszerű alkalmazásarchitektúra

A lecke végére
- megnevezed az alkalmazás rétegeit, és tudod, melyik mit tartalmaz;
- a függőségek irányát helyesen tartod (a modell senkit sem ismer, a felület a legkülső réteg);
- logikus mappa- és névtér-szerkezetet alakítasz ki;
- többprojektes megoldást hozol létre, ahol a fordító kikényszeríti a szabályokat;
- egyszerű nézet–presenter mintát használsz, ha a Form logikája bonyolult;
- végigköveted egy művelet útját, és felismered a tipikus építészeti hibákat.
Mielőtt elkezded
Ehhez a leckéhez a 12.1–12.8 lecke szükséges (különösen az interface és a függőség átadása).
A rétegek és a függőségek iránya
| Réteg | Mi van benne? | Kit ismerhet? |
|---|---|---|
Modell (Models) | Adatok és a rájuk vonatkozó szabályok (Feladat, Nevjegy). | Senkit (csak a .NET alap osztályait). |
Szolgáltatás (Services) | Az alkalmazás műveletei és számításai (FeladatLista, HitelSzamolo). | A modellt és a tároló interface-ét. |
Tároló (Storage) | Mentés és betöltés (JsonFeladatTarolo). | A modellt és a saját interface-ét. |
Felület (Forms) | A Formok és a megjelenítés, a felhasználói események. | A szolgáltatást és a modellt (de nem a tárolót). |
| Program.cs | Az összeállítás (composition root). | Mindenkit: itt készülnek a példányok. |
Forms (felület) ───────▶ Services (szolgáltatás) ───────▶ Models (modell)
│ ▲
▼ │
IFeladatTarolo (interface) ◀─── Storage (tároló)
A modell SENKIT nem ismer.
A szolgáltatás az INTERFACE-t ismeri, nem a konkrét tárolót.
A Forms nem ismeri a Storage réteget; csak a Program.cs köti össze őket.Jósolj!
A Feladat modell Elvegez() metódusa a végén MainForm.Instance.Frissit() hívást tartalmaz, hogy a felület frissüljön. Melyik szabályt sérti ez?
Jóslat ellenőrzése
A függőség irányát: a legbelső réteg (modell) ismeri a legkülsőt (felület). A modell többé nem használható felület nélkül, nem tesztelhető, és körkörös függőség jön létre. A modell eseménnyel jelezhet; a felület feliratkozik rá.
Mappaszerkezet, névterek, többprojektes megoldás
Naplo.sln
├── Naplo.Core (osztálykönyvtár: NINCS hivatkozása a Windows Forms-ra)
│ ├── Models/ NaploBejegyzes.cs
│ ├── Services/ NaploSzolgaltatas.cs, INaploTarolo.cs
│ └── Storage/ JsonNaploTarolo.cs, MemoriaNaploTarolo.cs
├── Naplo.App (Windows Forms alkalmazás; hivatkozik a Naplo.Core-ra)
│ ├── Forms/ NaploForm.cs, SzerkesztoForm.cs
│ ├── Presenters/ NaploPresenter.cs
│ └── Program.cs az összeállítás
└── Naplo.Tests (tesztprojekt; hivatkozik a Naplo.Core-ra)
└── NaploSzolgaltatasTesztek.cs// a névtér a mappát követi: minden fájl elején megmutatja, melyik rétegé
namespace Naplo.Core.Models
{
public class NaploBejegyzes { /* ... */ }
}
namespace Naplo.Core.Services
{
using Naplo.Core.Models; // a szolgáltatás a modellt ismeri
public interface INaploTarolo { /* ... */ }
}
namespace Naplo.App.Forms
{
using Naplo.Core.Services; // a felület a szolgáltatást ismeri; Storage-ra NINCS using
}Naplo.Core) a Windows Forms projekt hivatkozik, fordítva nem. Így a fordító kikényszeríti, hogy a Core nem ismeri a felületet: ha valaki MessageBox-ot írna a Core-ba, nem fordul.Jósolj!
A Naplo.Core osztálykönyvtárban valaki MessageBox.Show(...) hívást ír. A projekt nem hivatkozik a System.Windows.Forms-ra. Mi történik?
Jóslat ellenőrzése
Fordítási hiba (CS0103 vagy CS0234: a MessageBox / System.Windows.Forms nem létezik). A szerkezet maga védi az architektúrát: nem a fegyelemre, hanem a fordítóra épít.
Nézet és presenter: egyszerű MVP
// A NÉZET szerződése: mit tud a felület (nincs benne vezérlő-típus!)
public interface INaploNezet
{
string Szoveg { get; set; } // a beírt szöveg
void MutatBejegyzesek(IEnumerable<string> sorok);
void MutatUzenet(string uzenet);
event EventHandler HozzaadKerve; // a felület jelez, nem hív
}
// A PRESENTER: a nézet és a szolgáltatás között közvetít; semmi WinForms-specifikus
public class NaploPresenter
{
private readonly INaploNezet nezet;
private readonly NaploSzolgaltatas szolgaltatas;
public NaploPresenter(INaploNezet nezet, NaploSzolgaltatas szolgaltatas)
{
this.nezet = nezet;
this.szolgaltatas = szolgaltatas;
nezet.HozzaadKerve += (s, e) => Hozzaad();
Frissit();
}
private void Hozzaad()
{
try
{
szolgaltatas.Hozzaad(nezet.Szoveg);
nezet.Szoveg = "";
Frissit();
}
catch (ArgumentException ex) { nezet.MutatUzenet(ex.Message); }
}
private void Frissit()
{
nezet.MutatBejegyzesek(szolgaltatas.Bejegyzesek.Select(b => b.Idopont.ToString("yyyy.MM.dd HH:mm") + " " + b.Szoveg));
}
}
// A FORM: megvalósítja a nézetet; vékony: csak továbbítja és megjeleníti
public partial class NaploForm : Form, INaploNezet
{
public event EventHandler HozzaadKerve;
public NaploForm() { InitializeComponent(); }
public string Szoveg { get { return txtSzoveg.Text; } set { txtSzoveg.Text = value; } }
public void MutatBejegyzesek(IEnumerable<string> sorok) { lstNaplo.Items.Clear(); lstNaplo.Items.AddRange(sorok.ToArray()); }
public void MutatUzenet(string uzenet) { tsslUzenet.Text = uzenet; }
private void btnHozzaad_Click(object sender, EventArgs e) { HozzaadKerve?.Invoke(this, EventArgs.Empty); }
}
// Program.cs: összeállítás
NaploForm form = new NaploForm();
NaploPresenter presenter = new NaploPresenter(form, new NaploSzolgaltatas(new JsonNaploTarolo(ut)));
Application.Run(form);INaploNezet-et valósítja meg) kipróbálod a viselkedést.if. Egy ilyen Formban kevés a hiba, és a rossz részeket a Designer sem bonyolítja.Jósolj!
Tesztet írsz a NaploPresenter-re, és egy HamisNezet osztályt készítesz (az INaploNezet megvalósítása egyszerű mezőkkel). A teszt beállítja a Szoveg-et, és kivált egy HozzaadKerve eseményt. Mit ellenőrizhetsz egyetlen vezérlő nélkül?
Jóslat ellenőrzése
Szinte mindent a felületi viselkedésből: a HamisNezet.MutatottSorok tartalmát (frissült-e a lista), az üzenetet üres szövegnél (hibaüzenet érkezett-e), és hogy a Szoveg kiürült-e a hozzáadás után. Ablak, Application.Run, kattintás nélkül.
Egy művelet útja az alkalmazásban
A „Hozzáad” gomb útja
- Form: a gomb
Clickeseménye kiváltja aHozzaadKerveeseményt. - Presenter: beolvassa a
nezet.Szovegértékét, és meghívja a szolgáltatást. - Szolgáltatás: létrehozza a
NaploBejegyzesmodellt (ellenőrzés a modellben), hozzáadja a listához,Modositott = true. - Tároló: a mentéskor
Ment()(a szolgáltatás hívja az interface-en át). - Vissza: a presenter frissíti a nézetet (
MutatBejegyzesek), a Form megjeleníti.
A hiba útja
- Modell: üres szövegnél
ArgumentException(a szabály itt él). - Szolgáltatás: nem kezeli (nem tudja, hogyan kell megjeleníteni), továbbengedi.
- Presenter: elkapja, és a
nezet.MutatUzenethívja. - Form: megjeleníti az állapotsorban.
A hiba kifelé halad, a réteg-határokon, és ott kezelik, ahol a megjelenítés eldől.
static állapot, modell a felületet hívja, körkörös függőség, a Form közvetlenül a tárolót hívja, a szolgáltatás MessageBox-ot hív.Jósolj!
A szolgáltatás Hozzaad metódusa hiba esetén MessageBox.Show(...)-ot hív, és nem dob kivételt. A presenter tesztjét hamis nézettel futtatod. Mi történik?
Jóslat ellenőrzése
A teszt felugró ablakot nyit (és vár a gombnyomásra), vagy a Core projektben nem is fordul. A szolgáltatás nem ismerheti a felületet: kivételt dob, és a presenter jelzi a nézeten (MutatUzenet), amit a hamis nézet rögzít, és a teszt ellenőrizhet.
Mit tegyél ide: a napló-alkalmazás Solution Explorer nézete a három projekttel és a mappaszerkezettel.Javasolt fájlnév: winforms-12-9-szerkezet.webp · Alt szöveg: A napló-alkalmazás projektszerkezete
Hibakeresés: te vagy a hibadetektív
Mi a baj, és hogyan javítod?
Gondold végig az okot, és csak utána nyisd le a megoldást.
1. A modell a Formot hívja
public void Elvegez() { Kesz = true; Application.OpenForms[0].Refresh(); }Hiba és javítás
A belső réteg ismeri a külsőt. Váltson ki eseményt (Valtozott), amelyre a felület (vagy presenter) feliratkozik.
2. A Form közvetlenül a tárolót hívja
new JsonNaploTarolo("naplo.json").Ment(lista); // a NaploForm-banHiba és javítás
A felület átugrotta a szolgáltatást, és a konkrét tárolót ismeri. Hívja a szolgáltatás Ment() metódusát; a tárolót a Program.cs adja át.
3. Körkörös hivatkozás
A Visual Studio azt jelzi: A circular dependency was detected a két projekt között. A Core hivatkozik az App-ra, az App a Core-ra. A Core ne hivatkozzon a felületre; a közös szerződéseket (interface) tedd a Core-ba.Ok és javítás
4. Az interface a tárolóval együtt él
A szolgáltatás a Az interface a szolgáltatás oldalán lakjon (Storage névtérre hivatkozik, hogy elérje az ITarolo interface-t.Ok és javítás
Services), a megvalósítás a Storage-ban. Így a szolgáltatás nem függ a tároló rétegtől.
5. Minden egy projektben és mappában
Száz fájl áll egy mappában, és nem látszik, mi mivel függ össze. Rendezd rétegek szerint mappákba, és a névterek kövessék a mappákat. Nagyobb programnál osztálykönyvtárba tedd a logikát.Ok és javítás
6. Túlbonyolított kis program
Egy háromgombos programhoz presenter, nézet-interface és három projekt készült. Az architektúra a változás kezelésére való. Kis, rövid életű programnál elég a Form + logikai osztály; bővíts, ha a program nő (és a fájlok kezelhetetlenek lesznek).Ok és javítás
Mini projekt: napló-alkalmazás váza
Építs egy bejegyzés-naplót, amelynek a lényege az architektúra: három projekt, jól elhatárolt rétegek, nézet–presenter minta:
- Megoldás:
Naplo.Core(osztálykönyvtár),Naplo.App(Windows Forms),Naplo.Tests(tesztprojekt vagy konzol-app); a hivatkozások iránya a leckében látott. - Models:
NaploBejegyzes(Idopont,Szoveg,Hangulatenum); a konstruktor ellenőrzi a szöveget (nem üres, legfeljebb 500 karakter). - Services:
INaploTarolo(Betolt,Ment);NaploSzolgaltatas(Hozzaad,Torol,Kereses(szoveg),Bejegyzesek,Modositott,Valtozottesemény;HangulatStatisztika()). - Storage:
JsonNaploTarolo(DTO, biztonságos mentés,TaroloHiba) ésMemoriaNaploTarolo. - Nézet és presenter:
INaploNezet(Szoveg,Hangulat,Keres,MutatBejegyzesek,MutatUzenet,MutatStatisztika, események:HozzaadKerve,TorlesKerve,KeresesValtozott,MentesKerve);NaploPresenter. - Form:
NaploFormmegvalósítja a nézetet; vékony; nincsifaz üzleti szabályokról; a mentés aszinkron (11. fejezet). - Program.cs: a composition root; kapcsolóval (
--memoria) a memória-tároló. - Tesztek: a szolgáltatás tesztje
MemoriaNaploTarolo-val; a presenter tesztjeHamisNezet-tel (hozzáadás üres szöveggel: hibaüzenet; hozzáadás érvényes szöveggel: a lista frissül, a szövegmező kiürül). - Architektúra-ellenőrzés: írj egy
ARCHITEKTURA.mdjegyzetet a rétegekről és a hivatkozásokról; ellenőrizd: aNaplo.Corenem hivatkozikSystem.Windows.Forms-ra, a Formok nem használnakusing Naplo.Core.Storage;sort. - Teszteld: az összes művelet, a hibás bemenetek, a tároló cseréje, a tesztek futtatása a Test Explorerben (vagy a Debug kimenetben).
Megoldás megnyitása (próbáld ki előbb önállóan!)
// ---- Naplo.Core/Models/NaploBejegyzes.cs ----
namespace Naplo.Core.Models
{
public enum Hangulat { Rossz, Kozepes, Jo }
public class NaploBejegyzes
{
public DateTime Idopont { get; private set; }
public string Szoveg { get; private set; }
public Hangulat Hangulat { get; private set; }
public NaploBejegyzes(string szoveg, Hangulat hangulat, DateTime idopont)
{
if (string.IsNullOrWhiteSpace(szoveg)) { throw new ArgumentException("A szöveg kötelező.", "szoveg"); }
if (szoveg.Length > 500) { throw new ArgumentException("Legfeljebb 500 karakter lehet.", "szoveg"); }
Szoveg = szoveg.Trim();
Hangulat = hangulat;
Idopont = idopont;
}
}
}
// ---- Naplo.Core/Services/NaploSzolgaltatas.cs ----
namespace Naplo.Core.Services
{
public interface INaploTarolo
{
List<NaploBejegyzes> Betolt();
void Ment(IEnumerable<NaploBejegyzes> bejegyzesek);
}
public class NaploSzolgaltatas
{
private readonly INaploTarolo tarolo;
private readonly List<NaploBejegyzes> lista = new List<NaploBejegyzes>();
private readonly Func<DateTime> ora;
public bool Modositott { get; private set; }
public IReadOnlyList<NaploBejegyzes> Bejegyzesek { get { return lista; } }
public event EventHandler Valtozott;
public NaploSzolgaltatas(INaploTarolo tarolo, Func<DateTime> ora = null)
{
if (tarolo == null) { throw new ArgumentNullException("tarolo"); }
this.tarolo = tarolo;
this.ora = ora ?? (() => DateTime.Now); // az idő függőség: tesztben rögzíthető
}
public void Betolt() { lista.Clear(); lista.AddRange(tarolo.Betolt()); Modositott = false; Jelez(); }
public void Ment() { tarolo.Ment(lista); Modositott = false; }
public void Hozzaad(string szoveg, Hangulat hangulat)
{
lista.Insert(0, new NaploBejegyzes(szoveg, hangulat, ora()));
Modositott = true;
Jelez();
}
public void Torol(NaploBejegyzes b) { if (lista.Remove(b)) { Modositott = true; Jelez(); } }
public IEnumerable<NaploBejegyzes> Kereses(string szoveg)
{
if (string.IsNullOrWhiteSpace(szoveg)) { return lista; }
return lista.Where(b => b.Szoveg.IndexOf(szoveg.Trim(), StringComparison.CurrentCultureIgnoreCase) >= 0);
}
public Dictionary<Hangulat, int> HangulatStatisztika()
{
return lista.GroupBy(b => b.Hangulat).ToDictionary(g => g.Key, g => g.Count());
}
private void Jelez() { Valtozott?.Invoke(this, EventArgs.Empty); }
}
}
// ---- Naplo.App/Presenters/NaploPresenter.cs ----
public interface INaploNezet
{
string Szoveg { get; set; }
Hangulat Hangulat { get; }
string Keres { get; }
void MutatBejegyzesek(IEnumerable<string> sorok);
void MutatUzenet(string uzenet);
void MutatStatisztika(string szoveg);
event EventHandler HozzaadKerve;
event EventHandler KeresesValtozott;
}
public class NaploPresenter
{
private readonly INaploNezet nezet;
private readonly NaploSzolgaltatas szolgaltatas;
public NaploPresenter(INaploNezet nezet, NaploSzolgaltatas szolgaltatas)
{
this.nezet = nezet;
this.szolgaltatas = szolgaltatas;
nezet.HozzaadKerve += (s, e) => Hozzaad();
nezet.KeresesValtozott += (s, e) => Frissit();
szolgaltatas.Valtozott += (s, e) => Frissit();
Frissit();
}
private void Hozzaad()
{
try { szolgaltatas.Hozzaad(nezet.Szoveg, nezet.Hangulat); nezet.Szoveg = ""; nezet.MutatUzenet(""); }
catch (ArgumentException ex) { nezet.MutatUzenet(ex.Message); }
}
private void Frissit()
{
nezet.MutatBejegyzesek(szolgaltatas.Kereses(nezet.Keres).Select(b => b.Idopont.ToString("yyyy.MM.dd HH:mm") + " " + b.Szoveg));
nezet.MutatStatisztika(string.Join(", ", szolgaltatas.HangulatStatisztika().Select(p => p.Key + ": " + p.Value)));
}
}
// ---- Tesztek: hamis nézet ----
public class HamisNezet : INaploNezet
{
public string Szoveg { get; set; }
public Hangulat Hangulat { get; set; }
public string Keres { get; set; }
public List<string> Sorok = new List<string>();
public string Uzenet = "";
public string Statisztika = "";
public event EventHandler HozzaadKerve;
public event EventHandler KeresesValtozott;
public void MutatBejegyzesek(IEnumerable<string> sorok) { Sorok = sorok.ToList(); }
public void MutatUzenet(string uzenet) { Uzenet = uzenet; }
public void MutatStatisztika(string szoveg) { Statisztika = szoveg; }
public void KivaltHozzaad() { HozzaadKerve?.Invoke(this, EventArgs.Empty); }
}
public static class PresenterTesztek
{
public static int Mindet()
{
int hibak = 0;
Action<bool, string> igaz = (f, nev) => { if (!f) { hibak++; System.Diagnostics.Debug.WriteLine("HIBA " + nev); } else { System.Diagnostics.Debug.WriteLine("ok " + nev); } };
HamisNezet nezet = new HamisNezet { Szoveg = " ", Keres = "" };
NaploSzolgaltatas szolg = new NaploSzolgaltatas(new MemoriaNaploTarolo(), () => new DateTime(2025, 1, 2, 8, 30, 0));
NaploPresenter p = new NaploPresenter(nezet, szolg);
nezet.KivaltHozzaad();
igaz(nezet.Uzenet.Length > 0 && nezet.Sorok.Count == 0, "üres szövegnél hibaüzenet, nincs bejegyzés");
nezet.Szoveg = "Jó nap volt";
nezet.KivaltHozzaad();
igaz(nezet.Sorok.Count == 1 && nezet.Sorok[0].StartsWith("2025.01.02 08:30"), "érvényes szövegnél a lista frissül, rögzített idővel");
igaz(nezet.Szoveg == "", "a szövegmező kiürül");
igaz(nezet.Uzenet == "", "a hibaüzenet törlődik");
return hibak;
}
}Mit tegyél ide: a kész napló-alkalmazás és a Test Explorer zöld tesztekkel.Javasolt fájlnév: winforms-12-9-kesz.webp · Alt szöveg: A kész napló-alkalmazás
Három önálló gyakorlófeladat
Egyszerre csak egyet csinálj: tervezd meg, írd meg, futtasd, ellenőrizd, és csak utána nyisd ki a megoldást.
1. Rétegek meghatározása
Egy régebbi projektedben sorolj be 5 osztályt a megfelelő rétegbe. Adat és szabály: modell; számítás, műveletek: szolgáltatás; fájl/adatbázis: tároló; Form, Egy lehetséges megoldás
MessageBox, TextBox: felület. Ahol egy osztály több rétegbe is „lóg”, ott van a bontás helye.
2. Függőségi nyilak
Rajzold le a napló-alkalmazás függőségi nyilait, és jelöld, melyik szabálysértő. Helyes: Form → Presenter → Szolgáltatás → Modell; Tároló → Interface. Szabálysértő például: Szolgáltatás → Form, Modell → Szolgáltatás, Form → JsonTarolo.Egy lehetséges megoldás
3. Új funkció
Új funkció: a naplóbejegyzések exportálása CSV-be. Hova kerül az egyes részek? A CSV szöveg előállítása: tároló vagy külön Egy lehetséges megoldás
CsvExport osztály (Storage); a művelet hívása: szolgáltatás (Exportal(utvonal)); a fájlválasztó ablak és az üzenet: a Form/Presenter. A logika a Core-ban teszteléssel.
Gyakorlótár: 6 további feladat
Három szinten gyakorolhatsz: alap, közepes és kihívás. Csak a próbálkozás után nyisd le a megoldást.
Alap szint
1. A rétegek
Sorold fel a négy réteget, és mondd el, mi van bennük. Modell (adat és szabály), szolgáltatás (műveletek), tároló (mentés), felület (Formok).Megoldás megnyitása
2. Ki kit ismer?
Ismerheti-e a modell a szolgáltatást? Nem: a függőség befelé mutat, a modell a legbelső réteg.Megoldás megnyitása
Közepes szint
3. Hol él az interface?
Hová teszed az A szolgáltatás oldalára (Core/Services): a szolgáltatás igényli; a tároló megvalósítása a Storage-ban ismeri a szerződést.INaploTarolo interface-t, és miért?Megoldás megnyitása
4. Mit nyersz a nézet-interface-szel?
Mi a nézet-interface és a presenter előnye? A felületi logika felület nélkül tesztelhető (hamis nézettel), a Form vékony marad.Megoldás megnyitása
Kihívás szint
5. Új felület
Szeretnél konzolos felületet is ugyanahhoz a naplóhoz. Mi kell? Csak új felület: egy konzolos osztály, amely az Megoldás megnyitása
INaploNezet-et megvalósítja (vagy közvetlenül a szolgáltatást hívja); a Core változatlan. Ez a szétválasztás haszna.
6. Mikor túl sok?
Mikor felesleges a presenter? Nagyon egyszerű Formnál, ahol nincs számottevő felületi logika: ott az a hozzáadott fájl csak bonyolít. Az architektúra a változás kezelésére való, ne mindenre.Megoldás megnyitása
Gyors önellenőrzés
Válaszolj fejben, és csak utána nyisd le a választ. Befelé: a külső rétegek ismerik a belsőket. Senkit, csak az alap .NET-et. A fordító kikényszeríti a szabályt: a Core nem hivatkozhat a felületre. A Közvetít a nézet és a szolgáltatás között; a felületi logikát tartalmazza. Kifelé: modell → szolgáltatás → presenter → Form (ott jelenik meg). Olyan Form, amelyben minden logika, tárolás és megjelenítés egyben él.1. Merre mutat a függőség?
2. Mit ismer a modell?
3. Miért több projekt?
4. Mi a composition root?
Program.cs: itt áll össze az alkalmazás.5. Mit csinál a presenter?
6. Merre halad a hiba?
7. Mi az isten-Form?
Mit tudsz már?
- Megnevezed az alkalmazás rétegeit, és helyesen tartod a függőségek irányát.
- Mappa-, névtér- és többprojektes szerkezetet alakítasz ki.
- Nézet–presenter mintát használsz, és hamis nézettel teszted.
- Végigköveted egy művelet útját, és felismered az építészeti hibákat.
Következő lecke: Mini projekt: karbantartható WinForms alkalmazás
A 12. fejezet záró projektje: egy teljes, rétegzett, tesztelt alkalmazás követelményekkel és önértékeléssel.



