Seluruh mesin aplikasi catatan sudah berdiri dan setiap bagiannya teruji di terminal. Tinggal satu pertanyaan: bagaimana widget di layar menyuruh mesin bekerja? Pada Qt, jawabannya adalah signals dan slots: ketika pengguna menekan tombol, widget mengeluarkan sinyal — misalnya clicked() — dan fungsi yang terdaftar sebagai slot untuk sinyal itu dipanggil oleh Qt. Sketsa pada bagian ini menunjukkan bentuk penyambungannya. Ia ilustratif, karena lingkungan praktikum kita tidak menginstal Qt dan bukan itu fokus proyek: yang dibuktikan penuh di bagian penutup ini justru engine-nya, lewat harness pengujian yang menjalankan 24 pemeriksaan setiap kali dieksekusi.
Peta Sambungan Mesin dan Antarmuka #
Sebelum membaca kode, kita petakan alurnya. Widget tidak pernah menyentuh data catatan; widget hanya mengeluarkan sinyal. Slot penerima sinyal menerjemahkannya menjadi Kejadian dan mendorongnya ke AntreanKejadian, menghabiskan antrean lewat prosesSemua() agar mesin bekerja, lalu menyegarkan tampilan dari hasil mesin:
Perhatikan arah panjang terakhir: tampilan selalu dibangun dari data mesin, bukan sebaliknya. Karena arah data satu arah itu, antarmuka boleh didesain ulang sesukanya — mesin tidak ikut berubah.
Sketsa Qt yang Menghubungkan Widget ke Mesin #
Jendela catatan memakai tiga widget: QListWidget sebagai daftar catatan, QLineEdit untuk judul, dan QPlainTextEdit untuk isi, ditambah dua tombol. Deklarasinya menyimpan referensi ke MesinCatatan beserta antrean milik jendela:
class JendelaCatatan : public QWidget {
Q_OBJECT
public:
explicit JendelaCatatan(MesinCatatan& mesin, QWidget* induk = nullptr);
private slots: // slot: fungsi yang menjawab sinyal widget
void terimaTambah();
void terimaHapus();
private:
void tampilkanUlang();
MesinCatatan& mesin_;
AntreanKejadian antrean_;
QListWidget* daftarTampil_;
QLineEdit* isianJudul_;
QPlainTextEdit* isianIsi_;
QPushButton* tombolTambah_;
QPushButton* tombolHapus_;
};Penyambungan terjadi di constructor lewat connect(): sinyal clicked() dari masing-masing tombol disambungkan ke slot milik jendela. Isi slotnya pun pendek — dorong kejadian, habiskan antrean, segarkan tampilan:
JendelaCatatan::JendelaCatatan(MesinCatatan& mesin, QWidget* induk)
: QWidget(induk), mesin_(mesin) {
// ... pembuatan widget dan layout tidak ditampilkan ...
connect(tombolTambah_, &QPushButton::clicked,
this, &JendelaCatatan::terimaTambah);
connect(tombolHapus_, &QPushButton::clicked,
this, &JendelaCatatan::terimaHapus);
}
void JendelaCatatan::terimaTambah() {
antrean_.dorong({JenisKejadian::Tambah,
isianJudul_->text().toStdString(),
isianIsi_->toPlainText().toStdString(), 0});
antrean_.prosesSemua(mesin_);
tampilkanUlang();
}
void JendelaCatatan::terimaHapus() {
QListWidgetItem* terpilih = daftarTampil_->currentItem();
if (terpilih == nullptr) {
return; // tidak ada baris yang dipilih
}
antrean_.dorong({JenisKejadian::Hapus, "", "",
terpilih->data(Qt::UserRole).toInt()});
antrean_.prosesSemua(mesin_);
tampilkanUlang();
}
void JendelaCatatan::tampilkanUlang() {
daftarTampil_->clear();
for (int i = 0; i < mesin_.jumlah(); ++i) {
const Catatan& c = mesin_.pada(i);
QListWidgetItem* baris = new QListWidgetItem(c.judul, daftarTampil_);
baris->setData(Qt::UserRole, c.id); // id mesin menempel di baris
}
}Tiga hal dari sketsa ini layak digarisbawahi. Pertama, slot hanya mendorong kejadian: terimaTambah() tidak memanggil mesin_.tambah() — jalurnya sama persis dengan skenario antrean di bagian 3, sehingga perilaku yang diuji di terminal adalah perilaku yang dijalankan aplikasi. Kedua, tampilkanUlang() membaca mesin dan menata ulang daftar; data selalu mengalir dari mesin ke tampilan, tidak pernah sebaliknya. Ketiga, id catatan menempel pada tiap baris lewat Qt::UserRole, sehingga tombol Hapus menyasar id yang benar meski teks barisnya berubah. Sketsa ini mengikuti pola signals dan slots yang dibahas tuntas pada Section GUI dan Aplikasi Desktop.
Harness Pengujian untuk Seluruh Engine #
Lapisan gambar tidak bisa diuji di lingkungan ini, tetapi justru di situlah letak nilai pola engine diuji, UI digambar: kebenaran aplikasi berdiri di sisi yang bisa diuji. uji-mesin-catatan.cpp adalah harness mandiri — salinan engine disertakan di dalamnya, tanpa library pengujian eksternal — yang menjalankan empat kelompok uji dan mencetak ringkasan LULUS/GAGAL, mengikuti pola harness proyek chat server:
// uji-mesin-catatan.cpp — harness pengujian mandiri untuk engine catatan.
// Empat kelompok uji: (1) CRUD MesinCatatan, (2) persistensi ke file,
// (3) pencarian, (4) antrean kejadian; diakhiri ringkasan LULUS/GAGAL
// dan exit code yang mencerminkan hasilnya.
#include <cctype>
#include <deque>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
namespace {
int lulus = 0;
int gagal = 0;
void cek(bool syarat, const std::string& nama) {
if (syarat) {
++lulus;
std::cout << " LULUS " << nama << "\n";
} else {
++gagal;
std::cout << " GAGAL " << nama << "\n";
}
}
// ---------- MesinCatatan (sama dengan src/mesin-catatan.cpp) ----------
// Menyeragamkan teks ke huruf kecil agar pencarian seragam.
std::string kecilHuruf(std::string teks) {
for (char& c : teks) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
return teks;
}
// Satu catatan: id tetap, judul satu baris, isi teks bebas.
struct Catatan {
int id = 0;
std::string judul;
std::string isi;
};
// Engine catatan: tambah, ubah, hapus, daftar, dan cari — tanpa GUI.
class MesinCatatan {
public:
int tambah(const std::string& judul, const std::string& isi) {
Catatan c;
c.id = idBerikutnya;
++idBerikutnya;
c.judul = judul;
c.isi = isi;
daftar_.push_back(c);
return c.id;
}
// Menambah catatan dengan id tertentu (dipakai pemuatan file).
void tambahDenganId(int id, const std::string& judul,
const std::string& isi) {
Catatan c;
c.id = id;
c.judul = judul;
c.isi = isi;
daftar_.push_back(c);
if (id >= idBerikutnya) {
idBerikutnya = id + 1;
}
}
bool ubah(int id, const std::string& judulBaru, const std::string& isiBaru) {
Catatan* c = cariId(id);
if (c == nullptr) {
return false;
}
c->judul = judulBaru;
c->isi = isiBaru;
return true;
}
bool hapus(int id) {
for (std::size_t i = 0; i < daftar_.size(); ++i) {
if (daftar_[i].id == id) {
daftar_.erase(daftar_.begin() + i);
return true;
}
}
return false;
}
int jumlah() const {
return static_cast<int>(daftar_.size());
}
const Catatan& pada(int i) const {
return daftar_[static_cast<std::size_t>(i)];
}
// Mengumpulkan catatan yang judul atau isinya memuat kunci pencarian.
std::vector<Catatan> cari(const std::string& kunci) const {
const std::string kunciKecil = kecilHuruf(kunci);
std::vector<Catatan> hasil;
for (const Catatan& c : daftar_) {
if (kecilHuruf(c.judul).find(kunciKecil) != std::string::npos ||
kecilHuruf(c.isi).find(kunciKecil) != std::string::npos) {
hasil.push_back(c);
}
}
return hasil;
}
private:
Catatan* cariId(int id) {
for (Catatan& c : daftar_) {
if (c.id == id) {
return &c;
}
}
return nullptr;
}
std::vector<Catatan> daftar_;
int idBerikutnya = 1;
};
// ---------- Persistensi (sama dengan bagian 2) ----------
const std::string PEMISAH = "---";
// Menyimpan seluruh catatan: satu blok "[catatan <id>]" per catatan.
bool simpanKeFile(const MesinCatatan& mesin, const std::string& namaFile) {
std::ofstream keluar(namaFile);
if (!keluar) {
return false;
}
for (int i = 0; i < mesin.jumlah(); ++i) {
const Catatan& c = mesin.pada(i);
keluar << "[catatan " << c.id << "]\n"
<< c.judul << "\n"
<< c.isi << "\n"
<< PEMISAH << "\n";
}
return true;
}
// Memuat catatan dari file; false bila file tidak bisa dibuka.
bool muatDariFile(MesinCatatan& mesin, const std::string& namaFile) {
std::ifstream masuk(namaFile);
if (!masuk) {
return false;
}
const std::string awalan = "[catatan ";
std::string baris;
while (std::getline(masuk, baris)) {
if (!baris.starts_with(awalan) || baris.back() != ']') {
continue;
}
const int id = std::stoi(
baris.substr(awalan.size(), baris.size() - awalan.size() - 1));
std::string judul;
std::getline(masuk, judul);
std::string isi;
while (std::getline(masuk, baris) && baris != PEMISAH) {
if (!isi.empty()) {
isi += "\n";
}
isi += baris;
}
mesin.tambahDenganId(id, judul, isi);
}
return true;
}
// ---------- Antrean kejadian (sama dengan bagian 3) ----------
enum class JenisKejadian { Tambah, Ubah, Hapus, Tampilkan };
struct Kejadian {
JenisKejadian jenis;
std::string judul; // dipakai Tambah dan Ubah
std::string isi; // dipakai Tambah dan Ubah
int id = 0; // dipakai Ubah dan Hapus
};
// Antrean kejadian: UI mendorong, mesin memproses berurutan.
class AntreanKejadian {
public:
void dorong(const Kejadian& k) {
antrean_.push_back(k);
}
int jumlah() const {
return static_cast<int>(antrean_.size());
}
bool kosong() const {
return antrean_.empty();
}
void prosesSatu(MesinCatatan& mesin) {
const Kejadian k = antrean_.front();
antrean_.pop_front();
switch (k.jenis) {
case JenisKejadian::Tambah: {
const int id = mesin.tambah(k.judul, k.isi);
std::cout << "antrean: Tambah -> id " << id << " \""
<< k.judul << "\"\n";
break;
}
case JenisKejadian::Ubah: {
const bool ok = mesin.ubah(k.id, k.judul, k.isi);
std::cout << "antrean: Ubah id " << k.id << " -> "
<< (ok ? "berhasil" : "id tidak ditemukan") << "\n";
break;
}
case JenisKejadian::Hapus: {
const bool ok = mesin.hapus(k.id);
std::cout << "antrean: Hapus id " << k.id << " -> "
<< (ok ? "berhasil" : "id tidak ditemukan") << "\n";
break;
}
case JenisKejadian::Tampilkan:
std::cout << "antrean: Tampilkan -> " << mesin.jumlah()
<< " catatan\n";
for (int i = 0; i < mesin.jumlah(); ++i) {
const Catatan& c = mesin.pada(i);
std::cout << " [" << c.id << "] " << c.judul << "\n";
}
break;
}
}
void prosesSemua(MesinCatatan& mesin) {
while (!antrean_.empty()) {
prosesSatu(mesin);
}
}
private:
std::deque<Kejadian> antrean_;
};
} // namespace
int main() {
std::cout << "== Uji CRUD MesinCatatan\n";
MesinCatatan mesin;
const int idA = mesin.tambah("Belajar C++", "Engine diuji tanpa GUI.");
const int idB = mesin.tambah("Daftar belanja", "Telur, beras, kopi.");
const int idC = mesin.tambah("Ide proyek", "Antrean kejadian sederhana.");
cek(idA == 1 && idB == 2 && idC == 3, "tambah mengembalikan id 1, 2, 3");
cek(mesin.jumlah() == 3, "jumlah melaporkan 3 catatan");
cek(mesin.ubah(idB, "Daftar belanja pekan ini",
"Telur, beras, kopi, teh hijau."),
"ubah id 2 berhasil");
cek(mesin.pada(0).id == 1 && mesin.pada(0).judul == "Belajar C++",
"urutan daftar tetap terjaga setelah ubah");
cek(mesin.hapus(idA), "hapus id 1 berhasil");
cek(!mesin.hapus(idA), "hapus id yang sama kedua kali ditolak");
cek(!mesin.ubah(idA, "apa pun", "apa pun"),
"ubah id yang sudah dihapus ditolak");
cek(mesin.jumlah() == 2, "sisa 2 catatan setelah hapus");
std::cout << "== Uji persistensi ke file\n";
MesinCatatan salinan;
cek(simpanKeFile(mesin, "uji-catatan.txt"),
"simpan 2 catatan ke uji-catatan.txt");
cek(muatDariFile(salinan, "uji-catatan.txt"),
"muat kembali dari file yang baru dibuat");
bool sama = salinan.jumlah() == mesin.jumlah();
for (int i = 0; sama && i < mesin.jumlah(); ++i) {
sama = salinan.pada(i).id == mesin.pada(i).id &&
salinan.pada(i).judul == mesin.pada(i).judul &&
salinan.pada(i).isi == mesin.pada(i).isi;
}
cek(sama, "round-trip simpan+muat mempertahankan id, judul, isi");
cek(salinan.tambah("Catatan baru", "id lanjutan.") == 4,
"id berlanjut menjadi 4 setelah muat");
MesinCatatan kosong;
cek(!muatDariFile(kosong, "file-tidak-ada-uji.txt"),
"muat file hilang gagal dengan jelas");
cek(kosong.jumlah() == 0, "engine tetap terpakai setelah muat gagal");
std::cout << "== Uji pencarian\n";
cek(mesin.cari("belanja").size() == 1,
"cari \"belanja\" menemukan 1 catatan");
cek(mesin.cari("teh").size() == 1, "kunci ditemukan juga di isi catatan");
cek(mesin.cari("DAFTAR").size() == 1 && mesin.cari("daftar").size() == 1,
"pencarian tidak membedakan huruf besar/kecil");
cek(mesin.cari("palindrome").empty(),
"kunci tanpa hasil mengembalikan daftar kosong");
std::cout << "== Uji antrean kejadian\n";
MesinCatatan mesinUji;
AntreanKejadian antrean;
antrean.dorong({JenisKejadian::Tambah, "Belajar C++",
"Catatan pertama.", 0});
antrean.dorong({JenisKejadian::Tambah, "Rencana", "Catatan kedua.", 0});
antrean.dorong({JenisKejadian::Ubah, "Rencana pekan ini", "Isi baru.", 2});
antrean.dorong({JenisKejadian::Hapus, "", "", 1});
antrean.dorong({JenisKejadian::Hapus, "", "", 99});
antrean.dorong({JenisKejadian::Tampilkan, "", "", 0});
cek(antrean.jumlah() == 6, "6 kejadian menunggu di antrean");
antrean.prosesSemua(mesinUji);
cek(antrean.kosong(), "antrean kosong setelah prosesSemua");
cek(mesinUji.jumlah() == 1, "hasil akhir: 1 catatan tersisa");
cek(mesinUji.pada(0).id == 2 &&
mesinUji.pada(0).judul == "Rencana pekan ini",
"Ubah diproses sebelum Hapus, urutan antrean terjaga");
cek(mesinUji.pada(0).isi == "Isi baru.", "isi hasil Ubah tersimpan utuh");
cek(!mesinUji.hapus(99), "Hapus id tak dikenal tidak mengubah engine");
std::cout << "== Ringkasan: " << lulus << " lulus, " << gagal
<< " gagal\n";
return (gagal == 0) ? 0 : 1;
}Output yang Dihasilkan:
== Uji CRUD MesinCatatan
LULUS tambah mengembalikan id 1, 2, 3
LULUS jumlah melaporkan 3 catatan
LULUS ubah id 2 berhasil
LULUS urutan daftar tetap terjaga setelah ubah
LULUS hapus id 1 berhasil
LULUS hapus id yang sama kedua kali ditolak
LULUS ubah id yang sudah dihapus ditolak
LULUS sisa 2 catatan setelah hapus
== Uji persistensi ke file
LULUS simpan 2 catatan ke uji-catatan.txt
LULUS muat kembali dari file yang baru dibuat
LULUS round-trip simpan+muat mempertahankan id, judul, isi
LULUS id berlanjut menjadi 4 setelah muat
LULUS muat file hilang gagal dengan jelas
LULUS engine tetap terpakai setelah muat gagal
== Uji pencarian
LULUS cari "belanja" menemukan 1 catatan
LULUS kunci ditemukan juga di isi catatan
LULUS pencarian tidak membedakan huruf besar/kecil
LULUS kunci tanpa hasil mengembalikan daftar kosong
== Uji antrean kejadian
LULUS 6 kejadian menunggu di antrean
antrean: Tambah -> id 1 "Belajar C++"
antrean: Tambah -> id 2 "Rencana"
antrean: Ubah id 2 -> berhasil
antrean: Hapus id 1 -> berhasil
antrean: Hapus id 99 -> id tidak ditemukan
antrean: Tampilkan -> 1 catatan
[2] Rencana pekan ini
LULUS antrean kosong setelah prosesSemua
LULUS hasil akhir: 1 catatan tersisa
LULUS Ubah diproses sebelum Hapus, urutan antrean terjaga
LULUS isi hasil Ubah tersimpan utuh
LULUS Hapus id tak dikenal tidak mengubah engine
== Ringkasan: 24 lulus, 0 gagalBedah Harness #
Mari kita telaah keputusan penting di dalamnya:
cek(syarat, nama): satu pintu untuk seluruh pemeriksaan; pencacah lulus dan gagal bertambah di satu tempat, sehingga ringkasan akhir tidak mungkin melenceng.- Kelompok CRUD: delapan pemeriksaan, termasuk penolakan ganda — hapus dan ubah terhadap id yang sudah dihapus mengembalikan
false, bukan mengubah data diam-diam. - Kelompok persistensi: hasil simpan lalu muat dibandingkan per kolom (id, judul, isi) untuk seluruh catatan; id berlanjut menjadi 4 setelah muat; file yang tidak ada membuat
muatDariFile()mengembalikanfalsedan engine tetap terpakai. - Kelompok pencarian: kunci pada judul, kunci pada isi, ketidaktergantungan huruf besar/kecil, dan daftar hasil kosong — empat jalur
cari()dicek satu per satu. - Kelompok antrean: enam kejadian termasuk
Hapus id 99yang tidak dikenal; urutan Ubah sebelum Hapus diverifikasi dari hasil akhir mesin, bukan hanya dari log. - Exit code:
main()mengembalikan 0 hanya bila seluruh pemeriksaan lulus — harness ini bisa langsung menjadi penghalang otomatis di pipeline.
Proyek ini menutup dengan pola yang bisa kita bawa ke aplikasi mana pun: logika data berdiri sendiri tanpa GUI, diuji hingga tuntas di terminal, dan antarmuka tinggal menggambar — mendorong kejadian masuk, menampilkan data keluar. Keterampilan yang sama berlaku saat kita menyusun aplikasi desktop Qt yang sesungguhnya pada Section GUI dan Aplikasi Desktop: widget dan sinyalnya boleh berbeda, tetapi MesinCatatan di dalamnya tidak perlu diubah sama sekali.
Rujukan resmi: Qt Documentation — Signals & Slots , GoogleTest Primer (pola yang ditiru) .