Dengan penyimpanan yang bertahan, fitur aplikasi catatan sudah lengkap: membuat, mengedit, menghapus, mencari, dan menemukan seluruh catatan tetap ada di buka berikutnya — dua puluh sembilan uji pun hijau. Namun “bekerja” dan “rapi” bukan kata yang sama. Buka kembali catatan.mjs versi Bagian 3 dan amati dua hal: blok validasi judul dan isi yang sama tertulis lengkap dua kali — di buatCatatan dan di ubahCatatan — dan angka 100 tertanam langsung di kedua blok itu. Hari ini keduanya sinkron; berbulan-bulan kemudian, memperpanjang batas judul berarti mengingat dua lokasi sekaligus, dan satu lokasi yang terlewat menghasilkan perilaku yang berbeda antara membuat dan mengedit.
Bagian terakhir ini mengerjakan pekerjaan yang sifatnya berbeda: bukan menambah fitur, melainkan merapikan struktur tanpa mengubah satu pun perilaku — refactoring. Metodenya sudah kita baca pada chapter Refactoring JavaScript di section Architecture and Patterns: kunci perilaku dengan uji terlebih dahulu, ubah struktur dalam langkah kecil, lalu jalankan uji kembali dan pastikan semuanya tetap hijau. Sebelum menyentuh kode, kita menyusun dulu peta delapan suite uji — jaring pengamannya perlu terbaca sebelum dipercaya.
Peta Delapan Suite Uji #
Dua puluh sembilan uji terbagi mengikuti fungsi yang dijaganya, dengan satu suite terakhir yang merangkai beberapa fungsi sekaligus:
| Suite | Uji | Yang dikunci |
|---|---|---|
buatCatatan | 6 | bentuk catatan lengkap, urutan id, trim(), dua guard kosong, batas 100 karakter |
ubahCatatan | 4 | perubahan penuh dan parsial, guard pada nilai gabungan, null untuk id tak dikenal |
hapusCatatan | 2 | true bila ada yang terhapus, false bila tidak |
daftarCatatan | 1 | salinan yang setara tetapi terpisah dari state |
cariCatatan | 7 | kontrak pencarian: normalisasi, potongan kata, multi-kata |
simpanCatatan | 2 | string JSON dari daftar kosong dan daftar terisi |
muatCatatan | 6 | pemeriksaan berlapis: null, JSON rusak, bukan array, entri tidak lengkap, sinkron id |
siklus data | 1 | round-trip simpan–muat, id berlanjut setelah muat |
Peta semacam ini berguna justru saat mulai mengubah kode: perubahan yang menyentuh validasi akan ditahan suite buatCatatan dan ubahCatatan; perubahan pada pemuatan data dijaga muatCatatan ditambah siklus data. Berkas ujinya utuh pada akhir proyek — inilah seluruh isi tests/catatan.test.mjs:
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
buatCatatan,
ubahCatatan,
hapusCatatan,
daftarCatatan,
cariCatatan,
simpanCatatan,
muatCatatan,
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');
});
});
describe('cariCatatan', () => {
test('kata kunci kosong mengembalikan seluruh catatan', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
buatCatatan('Jadwal Belajar', 'Latihan module ES.');
const hasil = cariCatatan(daftarCatatan(), '');
assert.strictEqual(hasil.length, 2);
});
test('mencocokkan kata kunci pada judul', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
buatCatatan('Jadwal Belajar', 'Latihan module ES.');
const hasil = cariCatatan(daftarCatatan(), 'Resep');
assert.deepStrictEqual(hasil.map((c) => c.id), [1]);
});
test('mencocokkan kata kunci pada isi', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
buatCatatan('Jadwal Belajar', 'Latihan module ES.');
const hasil = cariCatatan(daftarCatatan(), 'module');
assert.deepStrictEqual(hasil.map((c) => c.id), [2]);
});
test('mencocokkan potongan kata', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
const hasil = cariCatatan(daftarCatatan(), 'gul');
assert.deepStrictEqual(hasil.map((c) => c.id), [1]);
});
test('pencarian tidak peka huruf besar-kecil dan spasi ujung', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
buatCatatan('Jadwal Belajar', 'Latihan module ES.');
assert.deepStrictEqual(
cariCatatan(daftarCatatan(), 'GULA').map((c) => c.id),
[1],
);
assert.deepStrictEqual(
cariCatatan(daftarCatatan(), ' kopi ').map((c) => c.id),
[1],
);
});
test('semua kata harus ditemukan, boleh terpisah judul dan isi', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
buatCatatan('Jadwal Belajar', 'Latihan module ES.');
assert.deepStrictEqual(
cariCatatan(daftarCatatan(), 'kopi susu').map((c) => c.id),
[1],
);
assert.deepStrictEqual(
cariCatatan(daftarCatatan(), 'belajar module').map((c) => c.id),
[2],
);
});
test('tanpa kecocokan mengembalikan array kosong', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
assert.deepStrictEqual(cariCatatan(daftarCatatan(), 'kamera'), []);
});
});
describe('simpanCatatan', () => {
test('daftar kosong menjadi teks "[]"', () => {
resetCatatan();
assert.strictEqual(simpanCatatan(), '[]');
});
test('menghasilkan teks JSON dari seluruh catatan', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
buatCatatan('Jadwal Belajar', 'Latihan module ES.');
assert.strictEqual(
simpanCatatan(),
'[{"id":1,"judul":"Resep Kopi","isi":"Gula aren dan susu."},{"id":2,"judul":"Jadwal Belajar","isi":"Latihan module ES."}]',
);
});
});
describe('muatCatatan', () => {
test('null dikembalikan sebagai daftar kosong tanpa alasan', () => {
resetCatatan();
assert.deepStrictEqual(muatCatatan(null), { catatan: [], alasan: null });
});
test('mengurai teks JSON menjadi catatan tersimpan', () => {
resetCatatan();
const hasil = muatCatatan('[{"id":7,"judul":"Catatan Lama","isi":"Isi lama."}]');
assert.deepStrictEqual(hasil, {
catatan: [{ id: 7, judul: 'Catatan Lama', isi: 'Isi lama.' }],
alasan: null,
});
assert.deepStrictEqual(daftarCatatan(), hasil.catatan);
});
test('teks yang bukan JSON dikembalikan dengan alasan', () => {
resetCatatan();
buatCatatan('Catatan Utuh', 'Tidak boleh ikut terhapus.');
const hasil = muatCatatan('{bukan json');
assert.deepStrictEqual(hasil.catatan, []);
assert.match(hasil.alasan, /Bukan JSON yang valid/);
assert.strictEqual(daftarCatatan().length, 1);
});
test('JSON yang bukan daftar ditolak dengan alasan', () => {
resetCatatan();
const hasil = muatCatatan('{"judul":"bukan array"}');
assert.deepStrictEqual(hasil.catatan, []);
assert.match(hasil.alasan, /bukan daftar catatan/);
});
test('entri yang tidak lengkap dilewati', () => {
resetCatatan();
const hasil = muatCatatan(
'[{"id":1,"judul":"Sah","isi":"Isi sah."},{"judul":"tanpa id"},{"id":3,"isi":"tanpa judul"},"bukan object"]',
);
assert.deepStrictEqual(hasil.catatan, [
{ id: 1, judul: 'Sah', isi: 'Isi sah.' },
]);
assert.strictEqual(hasil.alasan, null);
});
test('id berikutnya menyusul id tertinggi data yang dimuat', () => {
resetCatatan();
muatCatatan('[{"id":7,"judul":"Catatan Lama","isi":"Isi lama."}]');
const baru = buatCatatan('Catatan Baru', 'Isi baru.');
assert.strictEqual(baru.id, 8);
});
});
describe('siklus data', () => {
test('simpan lalu muat mengembalikan catatan yang sama', () => {
resetCatatan();
buatCatatan('Resep Kopi', 'Gula aren dan susu.');
buatCatatan('Jadwal Belajar', 'Latihan module ES.');
hapusCatatan(1);
const teks = simpanCatatan();
resetCatatan();
const hasil = muatCatatan(teks);
assert.strictEqual(hasil.alasan, null);
assert.deepStrictEqual(hasil.catatan, [
{ id: 2, judul: 'Jadwal Belajar', isi: 'Latihan module ES.' },
]);
assert.deepStrictEqual(daftarCatatan(), hasil.catatan);
const baru = buatCatatan('Ide Konten', 'Menulis catatan tentang pencarian.');
assert.strictEqual(baru.id, 3);
});
});Lima kebiasaan yang tampak dari berkas di atas layak dirangkum:
- Setiap
testdiawaliresetCatatan(): state kini tinggal di module, maka memulihkan keadaan awal adalah langkah Arrange yang eksplisit — tanpa itu, id sisa uji sebelumnya bocor ke uji berikutnya dan klaim sepertiid: 1menjadi tebakan. assert.deepStrictEqualmengunci bentuk utuh: uji pertama menuntut object lengkap{ id: 1, judul: ..., isi: ... }, bukan sekadar “memiliki judul”. Bentuk yang dikunci membuat perubahan tidak disadari jadi tidak mungkin lolos diam-diam.assert.throwsberpasangan dengan RegExp: pesan setiap guard validasi ikut dikunci. Aturan tak tertulis bagi refactoring: kode boleh dipindahkan, pesan tidak boleh bergeser.- Uji kegagalan memeriksa lebih dari satu hal:
teks yang bukan JSONmemastikan hasil kosong,alasanyang cocok pola, dan catatan yang sudah ada tetap utuh — satu insiden, tiga jaminan. - Suite
siklus dataadalah uji integrasi kecil: tiga fungsi dirangkai menjadi satu alur simpan–muat, lalu id baru menyusul id lama — jaminan bahwa potongan-potongan yang masing-masing hijau juga benar bila digabung.
Refactoring 1: Angka 100 Jadi Konstanta Bernama #
Kebiasaan menanam angka langsung di kode punya nama: magic number. Masalah utamanya bukan keterbacaan semata, melainkan duplikasi makna — angka yang sama dengan makna yang sama muncul di dua tempat tanpa satu pun petunjuk bahwa keduanya harus berubah bersama. Kondisi sebelum perapian, seperti yang tertulis di catatan.mjs versi Bagian 3:
export function buatCatatan(judul, isi) {
const judulBersih = judul.trim();
if (judulBersih === '') {
throw new Error('Judul tidak boleh kosong.');
}
if (judulBersih.length > 100) { // lokasi pertama
throw new Error('Judul melebihi 100 karakter.');
}
// ... dan di ubahCatatan, baris yang sama tertulis ulang:
// if (judulBersih.length > 100) { // lokasi kedua
Langkah pertama menaikkan angka itu menjadi konstanta yang dinyatakan sekali di atas berkas:
const BATAS_JUDUL = 100; // batas panjang judul, dinyatakan sekali di sini
let daftar = []; // state: array berisi object { id, judul, isi }
let penghitungId = 0; // sumber id: bertambah satu setiap catatan dibuat
Kedua pemeriksaan panjang kini membaca BATAS_JUDUL, dan pesan galatnya disusun dengan template literal — Judul melebihi ${BATAS_JUDUL} karakter. — sehingga teks yang dihasilkan tetap Judul melebihi 100 karakter., persis seperti yang diklaim uji. Mengganti batas kini satu baris, dan keempat penulisan (dua pemeriksaan, dua pesan) berpindah bersama secara otomatis. Di berkas akhir, pemeriksaan itu sendiri pindah ke dalam helper judulWajib — itulah langkah kedua di bawah.
Refactoring 2: Validasi Satu Rumah #
Janji kecil di Bagian 1 kini ditepati: duplikasi validasi yang “paling menggigit” justru menjadi bahan perapian. Ini wujudnya sebelum dirapikan — sebelas baris validasi di buatCatatan, lalu sebelas baris yang sama sekali serupa di ubahCatatan:
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 };
}Yang berbeda antara keduanya hanya sumber nilainya: buatCatatan memvalidasi argumen mentah, sedangkan ubahCatatan memvalidasi nilai gabungan hasil ??. Aturannya identik — pangkas, tolak bila kosong, batasi panjang judul. Maka aturan itu kita angkat menjadi dua helper yang dipakai bersama, sekaligus memberi penghitung id rumah kecilnya sendiri:
function teksWajib(namaField, nilai) {
const teks = nilai.trim();
if (teks === '') {
throw new Error(`${namaField} tidak boleh kosong.`);
}
return teks;
}
function judulWajib(nilai) {
const teks = teksWajib('Judul', nilai);
if (teks.length > BATAS_JUDUL) {
throw new Error(`Judul melebihi ${BATAS_JUDUL} karakter.`);
}
return teks;
}
function idBerikutnya() {
penghitungId += 1;
return penghitungId;
}
export function buatCatatan(judul, isi) {
const judulBersih = judulWajib(judul);
const isiBersih = teksWajib('Isi', isi);
const catatanBaru = { id: idBerikutnya(), 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 catatanBaru = {
...catatan,
judul: judulWajib(perubahan.judul ?? catatan.judul),
isi: teksWajib('Isi', perubahan.isi ?? catatan.isi),
};
daftar = daftar.map((item) => (item.id === id ? catatanBaru : item));
return { ...catatanBaru };
}Mari kita bedah hasil perapiannya:
- Baris 12–18 (
function teksWajib(namaField, nilai)): aturan “wajib ada” berlaku sama untuk judul maupun isi; yang berbeda hanya nama field di pesannya. ParameternamaFieldmembuat satu fungsi melayani keduanya —${namaField} tidak boleh kosong.menghasilkan pesan yang sama persis dengan versi sebelum perapian. - Baris 20–26 (
function judulWajib(nilai)): aturan judul bertumpuk di atasteksWajib, lalu menambah pemeriksaan panjang denganBATAS_JUDUL. Bertingkat, bukan menyalin: “kosong” ditangani sekali di bawah, batas panjang hanya milik judul. - Baris 28–31 (
function idBerikutnya()): penghitung naik dan dibaca di satu tempat. Nama kecil ini membuat baris 36 terbaca sebagai kalimat, bukan operasi aritmetika di tengah fungsi. - Baris 34–36 (badan
buatCatatan): fungsi menyusut dari tujuh belas menjadi tujuh baris. Perhatikan urutannya:judulWajibdanteksWajibdievaluasi sebelumidBerikutnya()dipanggil di baris 36 — kegagalan validasi tetap tidak menyisakan nomor yang terlewat di penghitung, sama seperti versi sebelumnya. - Baris 48–49 (validasi nilai gabungan): di
ubahCatatan,??tetap menyusun nilai kandidat, lalu helper yang memeriksa. Urutan properti object membuatjudulWajibdievaluasi sebelumteksWajib— urutan yang menjaga pesan galat pertama tetap konsisten. - Tidak ada
exportbaru: ketiga helper tetap detail internal. Kontrak module kepada dunia luar tidak berubah sedikit pun — antarmuka dan berkas uji tidak perlu menyentuh satu baris pun.
Berkas Akhir dan Bukti Uji Tetap Hijau #
Hasil kedua langkah terangkum dalam berkas final. Inilah catatan.mjs utuh pada akhir proyek — bandingkan dengan snapshot catatan.mjs di Bagian 3: jumlah ekspor tidak bertambah, nama fungsi tidak bergeser, pesan galat tidak berubah; yang berubah hanya susunan di dalamnya:
// 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.
const BATAS_JUDUL = 100; // batas panjang judul, dinyatakan sekali di sini
let daftar = []; // state: array berisi object { id, judul, isi }
let penghitungId = 0; // sumber id: bertambah satu setiap catatan dibuat
function teksWajib(namaField, nilai) {
const teks = nilai.trim();
if (teks === '') {
throw new Error(`${namaField} tidak boleh kosong.`);
}
return teks;
}
function judulWajib(nilai) {
const teks = teksWajib('Judul', nilai);
if (teks.length > BATAS_JUDUL) {
throw new Error(`Judul melebihi ${BATAS_JUDUL} karakter.`);
}
return teks;
}
function idBerikutnya() {
penghitungId += 1;
return penghitungId;
}
export function buatCatatan(judul, isi) {
const judulBersih = judulWajib(judul);
const isiBersih = teksWajib('Isi', isi);
const catatanBaru = { id: idBerikutnya(), 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 catatanBaru = {
...catatan,
judul: judulWajib(perubahan.judul ?? catatan.judul),
isi: teksWajib('Isi', perubahan.isi ?? catatan.isi),
};
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 }));
}
// Pencarian: fungsi murni yang menerima daftar catatan apa pun (biasanya
// hasil daftarCatatan()) dan mengembalikan array baru berisi yang cocok.
function normalisasiTeks(teks) {
return teks.toLowerCase().trim();
}
export function cariCatatan(catatan, kataKunci) {
const kunci = normalisasiTeks(kataKunci);
if (kunci === '') {
return [...catatan];
}
const kataKunciBanyak = kunci.split(/\s+/);
return catatan.filter((item) => {
const teks = normalisasiTeks(`${item.judul} ${item.isi}`);
return kataKunciBanyak.every((kataTunggal) => teks.includes(kataTunggal));
});
}
// Penyimpanan: module ini hanya bekerja dengan string JSON; pembungkus
// localStorage hidup di antarmuka (lihat index.html).
export function simpanCatatan() {
return JSON.stringify(daftar);
}
function catatanSah(item) {
return (
typeof item?.id === 'number' &&
typeof item?.judul === 'string' &&
typeof item?.isi === 'string'
);
}
export function muatCatatan(teksJson) {
if (teksJson === null) {
return { catatan: [], alasan: null };
}
let data;
try {
data = JSON.parse(teksJson);
} catch (galat) {
return { catatan: [], alasan: `Bukan JSON yang valid: ${galat.message}` };
}
if (!Array.isArray(data)) {
return { catatan: [], alasan: 'Data tersimpan bukan daftar catatan.' };
}
const hasil = data.filter(catatanSah).map((item) => ({ ...item }));
daftar = hasil;
penghitungId = hasil.reduce((tertinggi, item) => Math.max(tertinggi, item.id), 0);
return { catatan: daftarCatatan(), alasan: null };
}
// Helper untuk pengujian: mengembalikan penyimpanan ke keadaan awal agar
// setiap kasus uji berangkat dari daftar kosong yang sama.
export function resetCatatan() {
daftar = [];
penghitungId = 0;
}Sekarang buktinya, dengan protokol yang sudah kita baca di chapter Refactoring JavaScript. Sebelum menyentuh kode, seluruh uji dijalankan dan dicatat — keluarannya persis seperti akhir Bagian 3: dua puluh sembilan lulus. Setelah kedua langkah perapian diterapkan, uji dijalankan kembali dari folder proyek:
node --testOutput yang Dihasilkan:
▶ buatCatatan
✔ membuat catatan pertama dengan id 1 (1.195239ms)
✔ id berlanjut untuk catatan berikutnya (0.157599ms)
✔ judul dan isi dipangkas spasi di kedua ujungnya (0.139203ms)
✔ judul yang kosong setelah dipangkas ditolak (0.37704ms)
✔ isi yang kosong setelah dipangkas ditolak (0.148555ms)
✔ judul yang melebihi 100 karakter ditolak (0.113092ms)
✔ buatCatatan (3.189485ms)
▶ ubahCatatan
✔ mengubah judul dan isi catatan yang dicari (0.343467ms)
✔ field yang tidak disebut tidak ikut berubah (0.351339ms)
✔ judul yang kosong pada perubahan ditolak (0.289142ms)
✔ mengembalikan null bila id tidak ditemukan (0.154873ms)
✔ ubahCatatan (1.517565ms)
▶ hapusCatatan
✔ menghapus catatan dengan id yang dicari (1.119987ms)
✔ mengembalikan false bila id tidak ditemukan (0.141018ms)
✔ hapusCatatan (1.5759ms)
▶ daftarCatatan
✔ mengembalikan salinan yang setara tetapi terpisah (0.163528ms)
✔ daftarCatatan (0.218828ms)
▶ cariCatatan
✔ kata kunci kosong mengembalikan seluruh catatan (0.164507ms)
✔ mencocokkan kata kunci pada judul (0.142686ms)
✔ mencocokkan kata kunci pada isi (0.115828ms)
✔ mencocokkan potongan kata (0.064371ms)
✔ pencarian tidak peka huruf besar-kecil dan spasi ujung (0.065577ms)
✔ semua kata harus ditemukan, boleh terpisah judul dan isi (0.058344ms)
✔ tanpa kecocokan mengembalikan array kosong (0.04274ms)
✔ cariCatatan (0.811219ms)
▶ simpanCatatan
✔ daftar kosong menjadi teks "[]" (0.058942ms)
✔ menghasilkan teks JSON dari seluruh catatan (0.04927ms)
✔ simpanCatatan (0.157463ms)
▶ muatCatatan
✔ null dikembalikan sebagai daftar kosong tanpa alasan (0.20617ms)
✔ mengurai teks JSON menjadi catatan tersimpan (0.119705ms)
✔ teks yang bukan JSON dikembalikan dengan alasan (0.151722ms)
✔ JSON yang bukan daftar ditolak dengan alasan (0.063665ms)
✔ entri yang tidak lengkap dilewati (0.07704ms)
✔ id berikutnya menyusul id tertinggi data yang dimuat (0.046906ms)
✔ muatCatatan (0.821744ms)
▶ siklus data
✔ simpan lalu muat mengembalikan catatan yang sama (0.095561ms)
✔ siklus data (0.144957ms)
ℹ tests 29
ℹ suites 8
ℹ pass 29
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 78.875082Bandingkan daftarnya dengan keluaran node --test pada Bagian 3: delapan suite, dua puluh sembilan uji, baris demi baris dengan nama yang sama persis — hanya duration_ms yang berbeda, dan angka itu memang berubah-ubah antar mesin maupun antar eksekusi karena mengukur waktu, bukan kebenaran. (Keluaran di atas dicatat dari eksekusi nyata pada folder proyek berisi src/, styles/, dan tests/ — kondisi proyek Anda.) Dua kali hijau di sekeliling satu perubahan struktur: itulah refactoring yang terukur, bukan sekadar perapian yang diyakini.
Penutup Proyek #
Empat bagian ini mengikuti satu garis: Bagian 1 membangun fondasi CRUD dengan tiga belas uji, Bagian 2 menambah pencarian menjadi dua puluh, Bagian 3 memasang penyimpanan hingga dua puluh sembilan, dan Bagian 4 menutup dengan angka yang sama — bukan kebetulan, melainkan sengaja: bagian terakhir tidak menambah perilaku, hanya merapikan jalan ke arahnya. Beberapa keputusan desain proyek ini layak dibawa ke mana pun:
- State tinggal di module. Fungsi menjadi pendek, data punya satu rumah, dan antarmuka cukup bertanya — sementara
resetCatatanmenjawab konsekuensinya dengan jujur: pengujian butuh pintu kembali ke keadaan awal. - id dari penghitung, bukan dari acak. Angka berurutan membuat keluaran bisa diklaim persis — dari
deepStrictEqualpada bentuk catatan hingga string JSON lengkap — sesuatu yang tidak mungkin padacrypto.randomUUID(). - Penyimpanan dibelah dua lapis. Serialisasi dan validasi hidup sebagai fungsi string murni yang teruji di terminal;
localStoragedisambut oleh pembungkus tipis yang nyaris tak berpikir. - Refactoring berdiri di atas uji. Konstanta bernama dan dua helper mengurangi duplikasi tanpa mengubah perilaku, dibuktikan oleh daftar uji yang identik sebelum dan sesudahnya.
Kebiasaan-kebiasaan ini bukan milik aplikasi catatan — pola “logika murni yang teruji, tampilan yang tipis, perapian yang terlindungi” berlaku untuk aplikasi JavaScript apa pun yang akan Anda tulis selanjutnya. Perjalanan belajarnya pun belum selesai: section Sumber Belajar menyusun peta dokumentasi resmi JavaScript — dari MDN hingga spesifikasi ECMA-262 — sebagai titik berangkat menggali lebih dalam setiap konsep yang baru saja kita pakai.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles/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;
}