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:

SuiteUjiYang dikunci
buatCatatan6bentuk catatan lengkap, urutan id, trim(), dua guard kosong, batas 100 karakter
ubahCatatan4perubahan penuh dan parsial, guard pada nilai gabungan, null untuk id tak dikenal
hapusCatatan2true bila ada yang terhapus, false bila tidak
daftarCatatan1salinan yang setara tetapi terpisah dari state
cariCatatan7kontrak pencarian: normalisasi, potongan kata, multi-kata
simpanCatatan2string JSON dari daftar kosong dan daftar terisi
muatCatatan6pemeriksaan berlapis: null, JSON rusak, bukan array, entri tidak lengkap, sinkron id
siklus data1round-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:

  1. Setiap test diawali resetCatatan(): 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 seperti id: 1 menjadi tebakan.
  2. assert.deepStrictEqual mengunci 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.
  3. assert.throws berpasangan dengan RegExp: pesan setiap guard validasi ikut dikunci. Aturan tak tertulis bagi refactoring: kode boleh dipindahkan, pesan tidak boleh bergeser.
  4. Uji kegagalan memeriksa lebih dari satu hal: teks yang bukan JSON memastikan hasil kosong, alasan yang cocok pola, dan catatan yang sudah ada tetap utuh — satu insiden, tiga jaminan.
  5. Suite siklus data adalah 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:

js catatan.mjs — sebelum: 100 tertanam di dua tempat
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:

js catatan.mjs — sesudah: konstanta bernama 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:

js catatan.mjs — sebelum: dua blok validasi kembar
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:

js catatan.mjs — sesudah: tiga helper dan pemanggilnya
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:

  1. Baris 12–18 (function teksWajib(namaField, nilai)): aturan “wajib ada” berlaku sama untuk judul maupun isi; yang berbeda hanya nama field di pesannya. Parameter namaField membuat satu fungsi melayani keduanya — ${namaField} tidak boleh kosong. menghasilkan pesan yang sama persis dengan versi sebelum perapian.
  2. Baris 20–26 (function judulWajib(nilai)): aturan judul bertumpuk di atas teksWajib, lalu menambah pemeriksaan panjang dengan BATAS_JUDUL. Bertingkat, bukan menyalin: “kosong” ditangani sekali di bawah, batas panjang hanya milik judul.
  3. 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.
  4. Baris 34–36 (badan buatCatatan): fungsi menyusut dari tujuh belas menjadi tujuh baris. Perhatikan urutannya: judulWajib dan teksWajib dievaluasi sebelum idBerikutnya() dipanggil di baris 36 — kegagalan validasi tetap tidak menyisakan nomor yang terlewat di penghitung, sama seperti versi sebelumnya.
  5. Baris 48–49 (validasi nilai gabungan): di ubahCatatan, ?? tetap menyusun nilai kandidat, lalu helper yang memeriksa. Urutan properti object membuat judulWajib dievaluasi sebelum teksWajib — urutan yang menjaga pesan galat pertama tetap konsisten.
  6. Tidak ada export baru: 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:

js catatan.mjs
// 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:

bash
node --test

Output yang Dihasilkan:

text
▶ 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.875082

Bandingkan 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 resetCatatan menjawab konsekuensinya dengan jujur: pengujian butuh pintu kembali ke keadaan awal.
  • id dari penghitung, bukan dari acak. Angka berurutan membuat keluaran bisa diklaim persis — dari deepStrictEqual pada bentuk catatan hingga string JSON lengkap — sesuatu yang tidak mungkin pada crypto.randomUUID().
  • Penyimpanan dibelah dua lapis. Serialisasi dan validasi hidup sebagai fungsi string murni yang teruji di terminal; localStorage disambut 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
css
/* 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;
}