Aplikasi catatan terdengar sepele: tulis judul dan isinya, baca kembali, ubah bila ada yang salah, hapus bila tidak diperlukan. Namun di balik keempat gerakan itu ada pertanyaan desain yang harus dijawab sebelum kode pertama ditulis: data seperti apa yang mewakili satu catatan, di mana daftar catatan itu dikelola, dan bagaimana tombol Ubah mengembalikan isi catatan ke dalam form tanpa menduplikasi formnya. Bagian pertama proyek ini menjawab ketiganya — kita merancang bentuk data, menulis module logika pertama, menyusun antarmuka beserta CSS-nya, dan mengunci perilaku awal dengan tiga belas uji otomatis.
Pada proyek aplikasi todo, setiap fungsi logika menerima daftar sebagai argumen dan mengembalikan daftar baru — state berpindah tangan dari satu pemanggil ke pemanggil berikutnya. Proyek ini sengaja memilih jalan lain: state tinggal di dalam module, dan fungsinya cukup dipanggil dengan data yang relevan saja — buatCatatan(judul, isi), ubahCatatan(id, perubahan), hapusCatatan(id), daftarCatatan(). Tanda tangan fungsi menjadi lebih pendek, dan daftar catatan punya satu rumah yang jelas. Konsekuensinya harus dijawab dengan jujur: karena state hidup di satu tempat, pengujian butuh cara mengembalikan keadaan awal — dan itulah asal fungsi kelima, resetCatatan, yang akan kita lihat di bagian akhir bagian ini.
Empat Berkas, Empat Peran #
Pola berkasnya sama dengan dua proyek sebelumnya: logika murni terpisah dari tampilan, dan uji otomatis menjaga logikanya.
| Berkas | Peran |
|---|---|
src/catatan.mjs | Otak: menyimpan daftar catatan, membuat, mengubah, menghapus, dan menyalin. Tidak menyentuh DOM. |
src/index.html.txt | Wajah: form judul dan isi, daftar kartu catatan, dan skrip penghubung ke logika. |
styles/styles.css | Penampil: kartu aplikasi, form dua kolom, kartu catatan, dan tombolnya. |
tests/catatan.test.mjs | Penjaga: uji otomatis yang memastikan logika berperilaku sesuai kontrak. |
Pemisahan ini yang membuat seluruh logika bisa diuji lewat node --test tanpa membuka browser — kebiasaan yang berakar pada prinsip Separation of Concerns pada section Architecture and Patterns. Perbedaannya dengan todo hanya ada di siapa pemegang state: pada todo antarmuka yang menyimpan daftar dan mengoperkanya ke logika; di sini antarmuka cukup bertanya kepada module.
Merancang Bentuk Catatan #
Satu catatan memuat tiga hal: penunjuk alamatnya, judulnya, dan isinya. Maka satu catatan kita wakili satu object kecil:
{ id: 1, judul: 'Resep Kopi', isi: 'Seduh kopi, tambah gula aren.' }Field id berupa number yang unik di dalam daftar. Dua pertanyaan menyertai bentuk state ini:
- Dari mana datangnya
id? Node menyediakancrypto.randomUUID()yang menghasilkan id acak seperti"9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d". Id seperti itu unik dan nyaman dipakai di aplikasi nyata, tetapi nilainya tidak bisa ditebak — uji otomatis pun hanya bisa mengklaim “id-nya ada”, bukan “id-nya 1”. Karena proyek ini ingin setiap keluaran bisa diverifikasi persis, kita pilih penghitung: variabel internal yang naik satu setiap catatan dibuat, sehingga catatan pertama selaluid 1, berikutnyaid 2, dan seterusnya. - Aturan main apa yang menjaga judul dan isi? Keduanya dipangkas spasi di kedua ujungnya dengan
trim(), ditolak bila kosong setelah dipangkas, dan judul dibatasi 100 karakter — batas yang sama dengan atributmaxlength="100"pada kolom judul di antarmuka, sehingga HTML dan logika sepakat tentang judul yang sah.
Tabel berikut memperagakan operasi dasar Bagian 1 pada penyimpanan yang kosong:
| Panggilan | Hasil |
|---|---|
buatCatatan('Resep Kopi', 'Seduh kopi...') | catatan { id: 1, ... } tersimpan dan dikembalikan |
buatCatatan('Belanja', 'Telur, beras.') | catatan { id: 2, ... } tersimpan di belakang yang pertama |
ubahCatatan(1, { judul: 'Resep Kopi Gula Aren' }) | catatan id 1 diganti versi barunya; lainnya tetap |
hapusCatatan(2) | mengembalikan true, penyimpanan berisi satu catatan |
hapusCatatan(99) | mengembalikan false — tidak ada yang perlu dihapus |
Module Logika Pertama #
Berikut catatan.mjs versi pertama — berkas lengkap pada akhir Bagian 1:
// catatan.mjs — logika inti aplikasi catatan, terpisah dari tampilan (DOM).
// Seluruh keputusan data hidup di module ini: daftar catatan disimpan sebagai
// state module, dan setiap fungsi di bawah bekerja atas state itu tanpa
// menyentuh document maupun localStorage, sehingga seluruh perilakunya bisa
// dibuktikan dengan node --test dari terminal.
let daftar = []; // state: array berisi object { id, judul, isi }
let penghitungId = 0; // sumber id: bertambah satu setiap catatan dibuat
export function buatCatatan(judul, isi) {
const judulBersih = judul.trim();
if (judulBersih === '') {
throw new Error('Judul tidak boleh kosong.');
}
if (judulBersih.length > 100) {
throw new Error('Judul melebihi 100 karakter.');
}
const isiBersih = isi.trim();
if (isiBersih === '') {
throw new Error('Isi tidak boleh kosong.');
}
penghitungId += 1;
const catatanBaru = { id: penghitungId, judul: judulBersih, isi: isiBersih };
daftar = [...daftar, catatanBaru];
return { ...catatanBaru };
}
export function ubahCatatan(id, perubahan) {
const catatan = daftar.find((item) => item.id === id);
if (catatan === undefined) {
return null;
}
const judulBersih = (perubahan.judul ?? catatan.judul).trim();
if (judulBersih === '') {
throw new Error('Judul tidak boleh kosong.');
}
if (judulBersih.length > 100) {
throw new Error('Judul melebihi 100 karakter.');
}
const isiBersih = (perubahan.isi ?? catatan.isi).trim();
if (isiBersih === '') {
throw new Error('Isi tidak boleh kosong.');
}
const catatanBaru = { ...catatan, judul: judulBersih, isi: isiBersih };
daftar = daftar.map((item) => (item.id === id ? catatanBaru : item));
return { ...catatanBaru };
}
export function hapusCatatan(id) {
const sisa = daftar.filter((item) => item.id !== id);
if (sisa.length === daftar.length) {
return false;
}
daftar = sisa;
return true;
}
export function daftarCatatan() {
return daftar.map((item) => ({ ...item }));
}
// Helper untuk pengujian: mengembalikan penyimpanan ke keadaan awal agar
// setiap kasus uji berangkat dari daftar kosong yang sama.
export function resetCatatan() {
daftar = [];
penghitungId = 0;
}Mari kita bedah bagian-bagian pentingnya:
- Baris 7–8 (
let daftar = [];danlet penghitungId = 0;): dua variabel module-level inilah seluruh state aplikasi. Tidak adaexportpada keduanya — module menolak membiarkan pihak luar menyentuh penyimpanannya secara langsung, dan satu-satunya pintu keluar-masuk data adalah fungsi-fungsi di bawahnya.daftartidak pernah diubah di tempat: setiap operasi menggantinya dengan array baru. - Baris 11–17 (validasi judul):
trim()membersihkan spasi ujung, guard pertama menolak judul kosong dengan melemparError, dan guard kedua menolak judul yang lebih panjang dari 100 karakter. Validasi berjalan sebelum id dibuat, sehingga pembuatan yang gagal tidak menyisakan nomor yang terlewat di penghitung. - Baris 18–21 (validasi isi): isi wajib ada, tetapi tidak dibatasi panjangnya — catatan boleh sepanjang apa pun, sedangkan judul harus ringkas untuk tampil di kartu.
- Baris 22–25 (menyimpan): penghitung naik satu, object catatan baru dibentuk, dan
daftar = [...daftar, catatanBaru]mengganti array lama dengan array baru berisi seluruh catatan lama plus yang baru. Baris terakhir mengembalikan salinan ({ ...catatanBaru }), bukan object yang disimpan — pemanggil bebas mengutak-atik hasilnya tanpa mengganggu penyimpanan. - Baris 29–32 (mencari sebelum mengubah):
findmelacak catatan denganidyang dituju. Bila tidak ada, fungsi mengembalikannull— kontrak yang jelas untuk antarmuka: “tidak ada yang diubah”, bukan galat yang perlu ditangkap. - Baris 33–43 (perubahan parsial):
perubahan.judul ?? catatan.judulberarti “pakai judul baru bila diberikan, pakai yang lama bila tidak” — nullish coalescing yang memungkinkan pemanggil mengubah judul saja, isi saja, atau keduanya. Setelah itu, aturan validasi yang sama diberlakukan pada hasil penggabungannya. - Baris 44–45 (mengganti satu item): object catatan baru disusun dengan spread, lalu
mapmenyusun array pengganti: item ber-id yang sama diganti versi barunya, item lain mempertahankan referensinya. - Baris 50–55 (menghapus dan melapor):
filtermenyusun array tanpa catatan ber-id yang dituju. Trik kecilnya ada di baris 51: bila panjang array tidak berubah, berarti tidak ada yang cocok — fungsi mengembalikanfalsetanpa menggantidaftar. Bila berubah, penyimpanan diganti dan fungsi mengembalikantrue. Pemanggil selalu tahu apakah penghapusannya nyata. - Baris 58–59 (membaca):
daftarCatatan()mengembalikan array baru berisi salinan tiap catatan. Antarmuka boleh mengambil data sepenting apa pun tanpa bisa merusak state. - Baris 64–66 (kembali ke nol):
resetCatatan()mengosongkan daftar dan penghitung sekaligus. Fungsi ini ada karena state kini milik module — setiap kasus uji perlu berangkat dari keadaan yang sama agar id-nya bisa di klaim dengan pasti.
Membuktikan Logika dari Terminal #
Karena module ini bebas DOM, kelima fungsinya bisa dicoba langsung dari terminal. Berkas demo berikut menyimulasikan pemakaian singkat tanpa browser:
// demo-catatan.mjs — mencoba fungsi dasar catatan dari terminal.
import {
buatCatatan,
ubahCatatan,
hapusCatatan,
daftarCatatan,
} from './catatan.mjs';
buatCatatan(' Ide Resep ', 'Seduh kopi gula aren, tambah susu.');
buatCatatan('Belanja Mingguan', 'Telur, beras, kopi, gula aren.');
buatCatatan('Jadwal Belajar', 'Latihan module ES besok malam.');
console.log('Isi daftar saat ini:');
for (const catatan of daftarCatatan()) {
console.log(` #${catatan.id} [${catatan.judul}] ${catatan.isi}`);
}
const hasilUbah = ubahCatatan(2, {
judul: 'Belanja Mingguan',
isi: 'Telur, beras, kopi, gula aren, minyak.',
});
console.log('');
console.log('Hasil perubahan #2:', hasilUbah.judul, '->', hasilUbah.isi);
hapusCatatan(1);
console.log('');
console.log('Setelah #1 dihapus:');
for (const catatan of daftarCatatan()) {
console.log(` #${catatan.id} [${catatan.judul}]`);
}
console.log('');
console.log('Mengubah id 99:', ubahCatatan(99, { judul: 'X', isi: 'Y' }));
console.log('Menghapus id 99:', hapusCatatan(99));Jalankan dari folder bagian ini:
node demo-catatan.mjsOutput yang Dihasilkan:
Isi daftar saat ini:
#1 [Ide Resep] Seduh kopi gula aren, tambah susu.
#2 [Belanja Mingguan] Telur, beras, kopi, gula aren.
#3 [Jadwal Belajar] Latihan module ES besok malam.
Hasil perubahan #2: Belanja Mingguan -> Telur, beras, kopi, gula aren, minyak.
Setelah #1 dihapus:
#2 [Belanja Mingguan]
#3 [Jadwal Belajar]
Mengubah id 99: null
Menghapus id 99: falseTiga hal patut dicermati. Judul pertama tersimpan bersih sebagai Ide Resep tanpa spasi ujungnya — bukti kerja trim(). Baris penghapusan memperlihatkan bahwa id tidak bergeser: setelah #1 dihapus, yang tersisa tetap #2 dan #3, karena id menempel pada catatannya, bukan pada posisi array. Dua baris terakhir menegaskan kontrak id tak dikenal: mengubah dan menghapus 99 tidak melempar galat, cukup melaporkan null dan false.
Kerangka Antarmuka: index.html #
Berkas antarmuka disimpan sebagai index.html.txt — salin ulang sebagai index.html bila ingin mencobanya. Berikut wujudnya pada akhir Bagian 1:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aplikasi Catatan</title>
<link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
<main class="aplikasi">
<h1>Catatan Saya</h1>
<form id="form-catatan" class="form-catatan">
<input type="text" id="input-judul" name="judul" placeholder="Judul catatan..." maxlength="100" autocomplete="off">
<textarea id="input-isi" name="isi" rows="4" placeholder="Tulis isi catatan..."></textarea>
<div class="aksi-form">
<button type="button" id="tombol-batal" class="tombol-batal" hidden>Batal</button>
<button type="submit" id="tombol-kirim" class="tombol-kirim">Tambah</button>
</div>
</form>
<p id="pesan" class="pesan" hidden></p>
<ul id="daftar-catatan" class="daftar-catatan"></ul>
</main>
<script type="module">
import {
buatCatatan,
ubahCatatan,
hapusCatatan,
daftarCatatan,
} from './catatan.mjs';
let idSedangDiubah = null;
const form = document.querySelector('#form-catatan');
const inputJudul = document.querySelector('#input-judul');
const inputIsi = document.querySelector('#input-isi');
const tombolKirim = document.querySelector('#tombol-kirim');
const tombolBatal = document.querySelector('#tombol-batal');
const elemenDaftar = document.querySelector('#daftar-catatan');
const elemenPesan = document.querySelector('#pesan');
function bersihkanModeEdit() {
idSedangDiubah = null;
inputJudul.value = '';
inputIsi.value = '';
tombolKirim.textContent = 'Tambah';
tombolBatal.hidden = true;
}
function mulaiModeEdit(catatan) {
idSedangDiubah = catatan.id;
inputJudul.value = catatan.judul;
inputIsi.value = catatan.isi;
tombolKirim.textContent = 'Simpan Perubahan';
tombolBatal.hidden = false;
inputJudul.focus();
}
function render() {
elemenDaftar.textContent = '';
for (const catatan of daftarCatatan()) {
const item = document.createElement('li');
item.className = 'kartu';
const judul = document.createElement('h2');
judul.textContent = catatan.judul;
const isi = document.createElement('p');
isi.textContent = catatan.isi;
const aksi = document.createElement('div');
aksi.className = 'aksi-kartu';
const tombolUbah = document.createElement('button');
tombolUbah.className = 'tombol-ubah';
tombolUbah.textContent = 'Ubah';
tombolUbah.dataset.id = String(catatan.id);
const tombolHapus = document.createElement('button');
tombolHapus.className = 'tombol-hapus';
tombolHapus.textContent = 'Hapus';
tombolHapus.dataset.id = String(catatan.id);
aksi.append(tombolUbah, tombolHapus);
item.append(judul, isi, aksi);
elemenDaftar.append(item);
}
}
form.addEventListener('submit', (event) => {
event.preventDefault();
try {
if (idSedangDiubah === null) {
buatCatatan(inputJudul.value, inputIsi.value);
} else {
ubahCatatan(idSedangDiubah, {
judul: inputJudul.value,
isi: inputIsi.value,
});
}
bersihkanModeEdit();
elemenPesan.hidden = true;
render();
} catch (galat) {
elemenPesan.textContent = galat.message;
elemenPesan.hidden = false;
}
});
tombolBatal.addEventListener('click', () => {
bersihkanModeEdit();
elemenPesan.hidden = true;
});
elemenDaftar.addEventListener('click', (event) => {
if (event.target.matches('.tombol-ubah')) {
const id = Number(event.target.dataset.id);
const catatan = daftarCatatan().find((item) => item.id === id);
if (catatan !== undefined) {
mulaiModeEdit(catatan);
}
}
if (event.target.matches('.tombol-hapus')) {
const id = Number(event.target.dataset.id);
if (idSedangDiubah === id) {
bersihkanModeEdit();
}
hapusCatatan(id);
render();
}
});
render();
</script>
</body>
</html>Skrip di bagian bawah menghubungkan tampilan ke logika. Mari kita bedah baris-baris pentingnya:
let idSedangDiubah = null;: satu variabel yang membedakan dua peran form.nullberarti form sedang membuat catatan baru; angka berarti form sedang mengubah catatan dengan id itu. Dengan satu tanda ini, form Tambah dan form Edit adalah form yang sama.function mulaiModeEdit(catatan) { ... }: saat tombol Ubah diklik, isi catatan disalin ke kolom form, label tombol kirim berganti menjadi “Simpan Perubahan”, dan tombol Batal dimunculkan. Pola ini memindahkan data dari penyimpanan ke form dan kembali lagi — alih-alih membuat form kedua.if (idSedangDiubah === null) { buatCatatan(...) } else { ubahCatatan(...) }: pada submit, skrip bertanya pada mode sebelum menentukan fungsi logika mana yang dipanggil. Keduanya bisa melempar galat validasi, dan keduanya ditangani blokcatchyang sama.elemenPesan.textContent = galat.message;: pesan galat dari logika ditulis lewattextContent— bukaninnerHTML— sesuai aturan Safe DOM Manipulation. Pengguna yang menekan Tambah dengan judul kosong membaca “Judul tidak boleh kosong.” alih-alih mendapatkan catatan hampa.tombolUbah.dataset.id = String(catatan.id);: setiap tombol membawa id catatannya lewat data attribute, disimpan sebagai string lalu dikembalikan menjadi number denganNumber()saat dibaca — bentuk data yang sama persis dengan yang dipakai logika.event.target.matches('.tombol-ubah')danmatches('.tombol-hapus'): satu listener pada#daftar-catatanmelayani seluruh tombol di semua kartu (event delegation). Klik di bagian lain kartu tidak cocok dengan selector mana pun, sehingga diabaikan.if (idSedangDiubah === id) { bersihkanModeEdit(); }di penghapus: bila catatan yang sedang diedit justru dihapus, form dikosongkan — tanpa itu, pengguna menyimpan perubahan untuk id yang sudah tidak ada dan menerimanullyang membingungkan.
Tampilan Sederhana dengan CSS #
Berkas styles/styles.css menjaga tampilan tetap sederhana namun nyata — kartu aplikasi di tengah layar, form vertikal, kartu catatan bertumpuk rapi:
/* styles.css — tampilan aplikasi catatan */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
font-family: system-ui, sans-serif;
}
.aplikasi {
width: 380px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.aplikasi h1 {
margin: 0 0 16px;
font-size: 1.4rem;
}
.form-catatan {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.form-catatan input,
.form-catatan textarea {
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
}
.form-catatan textarea {
resize: vertical;
}
.aksi-form {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.tombol-kirim {
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: #3b82f6;
color: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.tombol-kirim:hover {
background-color: #2563eb;
}
.tombol-batal {
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: #e5e7eb;
font-size: 0.95rem;
cursor: pointer;
}
.tombol-batal:hover {
background-color: #d1d5db;
}
.pencarian {
margin-bottom: 12px;
}
.pencarian input {
width: 100%;
box-sizing: border-box;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
}
.pesan {
margin: 0 0 12px;
color: #b91c1c;
font-size: 0.9rem;
}
.ringkasan {
margin: 0 0 12px;
color: #6b7280;
font-size: 0.9rem;
}
.daftar-catatan {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 10px;
}
.kartu {
padding: 12px;
background-color: #f9fafb;
border-radius: 8px;
}
.kartu h2 {
margin: 0 0 6px;
font-size: 1.05rem;
}
.kartu p {
margin: 0 0 10px;
color: #374151;
white-space: pre-wrap;
}
.aksi-kartu {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.tombol-ubah {
padding: 6px 10px;
border: none;
border-radius: 6px;
background-color: #fde68a;
font-size: 0.85rem;
cursor: pointer;
}
.tombol-ubah:hover {
background-color: #fcd34d;
}
.tombol-hapus {
padding: 6px 10px;
border: none;
border-radius: 6px;
background-color: #fecaca;
font-size: 0.85rem;
cursor: pointer;
}
.tombol-hapus:hover {
background-color: #fca5a5;
}Empat aturan yang paling menentukan bentuk: body memusatkan kartu aplikasi dengan flexbox; .form-catatan menumpuk judul, isi, dan tombol secara vertikal (flex-direction: column) — form untuk judul pendek dan isi panjang lebih nyaman tegak daripada mendatar; .daftar-catatan menghilangkan bullet bawaan <ul> dan menata kartu dengan CSS Grid; dan .kartu p memakai white-space: pre-wrap agar baris baru yang diketik pengguna di dalam isi catatan tetap tampil saat dibaca kembali. Kelas .pencarian dan .ringkasan belum dipakai Bagian 1 — aturannya disiapkan sekarang agar Bagian 2 cukup menambah markup dan logika tanpa menyentuh CSS lagi.
Uji Pertama dengan node –test #
Sekarang kita kunci perilaku yang sudah ditulis. Berikut isi tests/catatan.test.mjs pada kondisi Bagian 1 — berkas uji tumbuh mengikuti pembahasan, dan seluruh uji di bawah tetap ada sampai akhir proyek:
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
buatCatatan,
ubahCatatan,
hapusCatatan,
daftarCatatan,
resetCatatan,
} from '../src/catatan.mjs';
describe('buatCatatan', () => {
test('membuat catatan pertama dengan id 1', () => {
resetCatatan();
const hasil = buatCatatan('Resep Kopi', 'Seduh dan catat rasanya.');
assert.deepStrictEqual(hasil, {
id: 1,
judul: 'Resep Kopi',
isi: 'Seduh dan catat rasanya.',
});
});
test('id berlanjut untuk catatan berikutnya', () => {
resetCatatan();
const pertama = buatCatatan('Satu', 'Isi satu.');
const kedua = buatCatatan('Dua', 'Isi dua.');
const ketiga = buatCatatan('Tiga', 'Isi tiga.');
assert.deepStrictEqual(
[pertama.id, kedua.id, ketiga.id],
[1, 2, 3],
);
});
test('judul dan isi dipangkas spasi di kedua ujungnya', () => {
resetCatatan();
const hasil = buatCatatan(' Resep Kopi ', ' Seduh dan catat. ');
assert.strictEqual(hasil.judul, 'Resep Kopi');
assert.strictEqual(hasil.isi, 'Seduh dan catat.');
});
test('judul yang kosong setelah dipangkas ditolak', () => {
resetCatatan();
assert.throws(() => buatCatatan(' ', 'Isi apa pun.'), /Judul tidak boleh kosong/);
});
test('isi yang kosong setelah dipangkas ditolak', () => {
resetCatatan();
assert.throws(() => buatCatatan('Judul apa pun', ' '), /Isi tidak boleh kosong/);
});
test('judul yang melebihi 100 karakter ditolak', () => {
resetCatatan();
assert.throws(
() => buatCatatan('a'.repeat(101), 'Isi apa pun.'),
/Judul melebihi 100 karakter/,
);
});
});
describe('ubahCatatan', () => {
test('mengubah judul dan isi catatan yang dicari', () => {
resetCatatan();
buatCatatan('Judul Awal', 'Isi awal.');
const hasil = ubahCatatan(1, { judul: 'Judul Baru', isi: 'Isi baru.' });
assert.deepStrictEqual(hasil, { id: 1, judul: 'Judul Baru', isi: 'Isi baru.' });
assert.deepStrictEqual(daftarCatatan()[0], hasil);
});
test('field yang tidak disebut tidak ikut berubah', () => {
resetCatatan();
buatCatatan('Judul Awal', 'Isi awal.');
const hasil = ubahCatatan(1, { judul: 'Judul Baru' });
assert.strictEqual(hasil.judul, 'Judul Baru');
assert.strictEqual(hasil.isi, 'Isi awal.');
});
test('judul yang kosong pada perubahan ditolak', () => {
resetCatatan();
buatCatatan('Judul Awal', 'Isi awal.');
assert.throws(() => ubahCatatan(1, { judul: ' ' }), /Judul tidak boleh kosong/);
});
test('mengembalikan null bila id tidak ditemukan', () => {
resetCatatan();
assert.strictEqual(ubahCatatan(99, { judul: 'X', isi: 'Y' }), null);
});
});
describe('hapusCatatan', () => {
test('menghapus catatan dengan id yang dicari', () => {
resetCatatan();
buatCatatan('Satu', 'Isi satu.');
buatCatatan('Dua', 'Isi dua.');
assert.strictEqual(hapusCatatan(1), true);
assert.deepStrictEqual(daftarCatatan().map((c) => c.id), [2]);
});
test('mengembalikan false bila id tidak ditemukan', () => {
resetCatatan();
buatCatatan('Satu', 'Isi satu.');
assert.strictEqual(hapusCatatan(99), false);
assert.strictEqual(daftarCatatan().length, 1);
});
});
describe('daftarCatatan', () => {
test('mengembalikan salinan yang setara tetapi terpisah', () => {
resetCatatan();
buatCatatan('Satu', 'Isi satu.');
const salinan = daftarCatatan();
assert.deepStrictEqual(salinan, daftarCatatan());
assert.notStrictEqual(salinan, daftarCatatan());
salinan[0].judul = 'diubah dari luar';
assert.strictEqual(daftarCatatan()[0].judul, 'Satu');
});
});Jalankan dari folder proyek:
node --testOutput yang Dihasilkan:
▶ buatCatatan
✔ membuat catatan pertama dengan id 1 (2.099425ms)
✔ id berlanjut untuk catatan berikutnya (0.171337ms)
✔ judul dan isi dipangkas spasi di kedua ujungnya (0.132715ms)
✔ judul yang kosong setelah dipangkas ditolak (0.358961ms)
✔ isi yang kosong setelah dipangkas ditolak (0.131029ms)
✔ judul yang melebihi 100 karakter ditolak (0.124755ms)
✔ buatCatatan (4.041544ms)
▶ ubahCatatan
✔ mengubah judul dan isi catatan yang dicari (0.258501ms)
✔ field yang tidak disebut tidak ikut berubah (0.128611ms)
✔ judul yang kosong pada perubahan ditolak (0.170168ms)
✔ mengembalikan null bila id tidak ditemukan (0.184336ms)
✔ ubahCatatan (0.986006ms)
▶ hapusCatatan
✔ menghapus catatan dengan id yang dicari (0.218775ms)
✔ mengembalikan false bila id tidak ditemukan (0.121092ms)
✔ hapusCatatan (0.479615ms)
▶ daftarCatatan
✔ mengembalikan salinan yang setara tetapi terpisah (0.161527ms)
✔ daftarCatatan (0.266507ms)
ℹ tests 13
ℹ suites 4
ℹ pass 13
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 71.158509Tiga belas uji lulus (pass 13, fail 0), dikelompokkan dalam empat suite yang mengikuti nama fungsinya. Beberapa asersi layak dicermati. deepStrictEqual pada uji pertama mengunci bentuk lengkap catatan baru — termasuk id 1 dari penghitung; inilah yang tidak mungkin diklaim bila id dihasilkan secara acak. assert.throws dengan RegExp mengunci pesan setiap guard validasi, pola yang sama dengan uji galat pada dua proyek sebelumnya. Uji “field yang tidak disebut tidak ikut berubah” memeriksa perubahan parsial: hanya judul yang dikirim, dan isi lama harus tetap utuh. Uji terakhir membuktikan salinan daftarCatatan() benar-benar terpisah: mengubah salinan tidak merembet ke penyimpanan.
Perhatikan juga pola kecil yang konsisten: setiap uji diawali resetCatatan(). Langkah ini adalah bagian Arrange dari pola Arrange-Act-Assert — karena state kini tinggal di module, memulihkan keadaan awal menjadi bagian eksplisit dari setiap kasus uji, bukan sulap yang terjadi diam-diam.
Ringkasan Bagian 1 #
Kerangka proyek telah berdiri: state daftar dan penghitung id tinggal di catatan.mjs, empat fungsi CRUD berjalan dengan validasi yang ketat, antarmuka memakai satu form untuk membuat dan mengedit, CSS sudah lengkap sampai ke kelas yang belum terpakai, dan tiga belas uji otomatis lulus. Satu kebutuhan masih menggantung: pengguna dengan sepuluh catatan harus menggulir satu per satu untuk menemukan catatan resepnya — dan itulah pekerjaan Bagian 2.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles.css
Unduh berkas/* styles.css — tampilan aplikasi catatan */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
font-family: system-ui, sans-serif;
}
.aplikasi {
width: 380px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.aplikasi h1 {
margin: 0 0 16px;
font-size: 1.4rem;
}
.form-catatan {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.form-catatan input,
.form-catatan textarea {
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
}
.form-catatan textarea {
resize: vertical;
}
.aksi-form {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.tombol-kirim {
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: #3b82f6;
color: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.tombol-kirim:hover {
background-color: #2563eb;
}
.tombol-batal {
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: #e5e7eb;
font-size: 0.95rem;
cursor: pointer;
}
.tombol-batal:hover {
background-color: #d1d5db;
}
.pencarian {
margin-bottom: 12px;
}
.pencarian input {
width: 100%;
box-sizing: border-box;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
}
.pesan {
margin: 0 0 12px;
color: #b91c1c;
font-size: 0.9rem;
}
.ringkasan {
margin: 0 0 12px;
color: #6b7280;
font-size: 0.9rem;
}
.daftar-catatan {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 10px;
}
.kartu {
padding: 12px;
background-color: #f9fafb;
border-radius: 8px;
}
.kartu h2 {
margin: 0 0 6px;
font-size: 1.05rem;
}
.kartu p {
margin: 0 0 10px;
color: #374151;
white-space: pre-wrap;
}
.aksi-kartu {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.tombol-ubah {
padding: 6px 10px;
border: none;
border-radius: 6px;
background-color: #fde68a;
font-size: 0.85rem;
cursor: pointer;
}
.tombol-ubah:hover {
background-color: #fcd34d;
}
.tombol-hapus {
padding: 6px 10px;
border: none;
border-radius: 6px;
background-color: #fecaca;
font-size: 0.85rem;
cursor: pointer;
}
.tombol-hapus:hover {
background-color: #fca5a5;
}