Aplikasi catatan kita kini nyaman dipakai dalam satu sesi: catatan dibuat, diubah, dihapus, dan disaring pencarian yang tidak peka huruf besar-kecil. Tetapi kelemahan yang dijanjikan di akhir Bagian 2 masih menunggu: tutup tab-nya, dan seluruh catatan ikut hilang. State aplikasi hidup di memori — array daftar di dalam catatan.mjs — dan memori itu dikosongkan setiap halaman ditutup. Sepuluh catatan yang ditulis hari ini tidak menyisakan apa pun besok pagi. Untuk sebuah aplikasi catatan, penyimpanan yang bertahan bukan pelengkap: itulah alasan aplikasinya ada.

Kita memasangnya dalam dua lapis yang dibelah dengan sadar. Lapis pertama tinggal di catatan.mjs dan bekerja murni dengan string JSON: simpanCatatan() mengubah daftar menjadi teks, sedangkan muatCatatan(teks) mengurai teks kembali menjadi daftar — dengan pemeriksaan berlapis yang melaporkan hasilnya sebagai object { catatan, alasan }. Lapis kedua adalah pembungkus localStorage yang tipis di antarmuka: dua fungsi kecil yang hanya mengantar-mengantar string ke penyimpanan browser. Pembelahan ini lahir dari keterbatasan yang nyata, bukan gaya-gayaan — localStorage hanya ada di browser, sedangkan seluruh logika pemeriksaan data harus tetap teruji lewat node --test. Uji proyek bertambah dari dua puluh menjadi dua puluh sembilan.

Dua Lapis Penyimpanan #

Sebelum menyentuh kode, mari kita sepakati siapa mengerjakan apa:

LapisTempatnyaTugasnyaDiuji node --test?
Serialisasi dan validasisrc/catatan.mjsmengubah daftar menjadi string JSON dan sebaliknya; memeriksa data yang dimuat berlapis-lapisya — fungsi murni, bebas DOM
Pembungkus localStoragesrc/index.html.txtsetItem dan getItem dengan satu kuncitidak — kode browser

Susunan ini membalik kebiasaan yang sering muncul: menulis localStorage.setItem('catatan', JSON.stringify(daftar)) langsung di antarmuka memang lebih pendek, tetapi seluruh keputusan tentang data sah dan data rusak ikut terjebak di tempat yang tidak bisa diuji. Dengan membelah, bagian yang menaruh keputusan — bentuk catatan yang sah, cara menolak data yang rusak, cara menyinkronkan penghitung id — terkunci uji dari terminal; bagian yang tersisa begitu tipis sehingga hampir tidak ada keputusan berarti di dalamnya.

Bagian Penyimpanan di catatan.mjs #

Bagian baru ditambahkan di akhir catatan.mjs, setelah fungsi pencarian dan sebelum resetCatatan. Ada satu ekspor untuk menyimpan, satu helper untuk memeriksa, dan satu ekspor untuk memuat:

js catatan.mjs — bagian penyimpanan
// 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 };
}

Mari kita bedah baris demi baris:

  1. Baris 84–86 (export function simpanCatatan()): tugasnya satu — mengembalikan JSON.stringify(daftar), teks JSON rapat satu baris tanpa indentasi. Fungsi ini tidak menyentuh penyimpanan mana pun: yang dikembalikan string, dan pemanggil yang memutuskan string itu dibawa ke mana. Karena hanya bekerja dengan string, seluruh perilakunya bisa diklaim dari terminal. Serialisasi juga aman untuk bentuk data kita: catatan hanya memuat number dan string, dua jenis yang JSON.stringify pertahankan tanpa kejutan.
  2. Baris 88–94 (function catatanSah(item)): syarat satu catatan lengkap — id berupa number, judul dan isi berupa string. Penulisan item?.id memakai optional chaining: bila item berupa null atau undefined (bisa saja terjadi pada entri aneh di data luar), ekspresi berhenti dan menghasilkan undefined tanpa melempar galat, lalu pemeriksaan typeof menolaknya dengan tenang. Helper ini tidak diekspor — detail internal module.
  3. Baris 97–99 (if (teksJson === null)): localStorage.getItem mengembalikan null ketika kunci belum pernah diisi — keadaan pemakaian pertama, bukan galat. Maka fungsi mengembalikan daftar kosong dengan alasan: null: tidak ada yang perlu dilaporkan, cukup mulai dari nol.
  4. Baris 100–105 (try … catch sekitar JSON.parse): teks yang bukan JSON membuat JSON.parse melempar SyntaxError. Alih-alih membiarkan program berhenti, catch mengubahnya menjadi hasil berisi alasan: "Bukan JSON yang valid: ..." — pesan galat bawaan ikut disertakan supaya penyebabnya bisa dilacak. Pemanggil menerima laporan, bukan program yang berhenti.
  5. Baris 106–108 (if (!Array.isArray(data))): JSON yang valid belum tentu benar bentuknya. Teks {"judul":"bukan array"} lolos JSON.parse tetapi bukan daftar catatan — ditolak dengan alasan yang lebih spesifik agar pemiliknya tahu apa yang salah.
  6. Baris 109 (data.filter(catatanSah).map(...)): lapis pemeriksaan terakhir — entri yang tidak lengkap (tanpa id, tanpa judul, atau bukan object) dilewati, entri yang sah disalin satu per satu dengan spread sehingga data luar tidak memegang referensi langsung ke state.
  7. Baris 110–111 (daftar = hasil; dan penghitungId = ...): state module diganti hasil yang sudah bersih, lalu penghitung id disamakan dengan id tertinggi lewat reduce dan Math.max. Tanpa baris kedua, catatan baru setelah muat bisa menabrak id lama — dua catatan ber-id sama adalah resep kebingungan saat mengedit dan menghapus.
  8. Baris 112 (return { catatan: daftarCatatan(), alasan: null }): hasil selalu object dua field, dan catatan diambil dari daftarCatatan() — salinan, konsisten dengan kontrak module sejak Bagian 1. Bentuk kembalian yang tetap inilah yang membuat antarmuka cukup memeriksa satu hal: alasan.

Membuktikan Siklus Simpan–Muat dari Terminal #

Demo berikut menyimulasikan kejadian yang di alam bebas terjadi antara dua sesi: catatan dibuat, satu dihapus, teksnya disimpan, lalu aplikasi “dibuka ulang” dari keadaan kosong dan teks itu dimuat kembali. Di terminal, membuka ulang disimulasikan dengan resetCatatan() — di browser, kejadian ini terjadi sendiri setiap halaman dimuat. Demo ditutup dengan menyengaja memuat teks rusak:

// demo-penyimpanan.mjs — membuktikan siklus simpan-muat tanpa browser.
import {
  buatCatatan,
  hapusCatatan,
  resetCatatan,
  simpanCatatan,
  muatCatatan,
  daftarCatatan,
} from './catatan.mjs';

resetCatatan();
buatCatatan('Resep Kopi Gula Aren', 'Seduh kopi, tambah gula aren dan susu.');
buatCatatan('Jadwal Belajar', 'Latihan module ES besok malam.');
hapusCatatan(1);

const teksJson = simpanCatatan();
console.log('Teks yang disimpan:');
console.log(teksJson);

// Simulasi aplikasi dibuka kembali dari keadaan kosong.
resetCatatan();
console.log('');
console.log('Setelah dibuka ulang:', daftarCatatan().length, 'catatan');

const hasil = muatCatatan(teksJson);
console.log('Muat ulang ->', hasil.catatan.length, 'catatan, alasan:', hasil.alasan);
for (const catatan of daftarCatatan()) {
  console.log(`  #${catatan.id} ${catatan.judul}`);
}

const baru = buatCatatan('Ide Konten', 'Menulis catatan tentang pencarian.');
console.log('Id catatan baru setelah muat:', baru.id);

console.log('');
const hasilRusak = muatCatatan('{bukan json');
console.log('Muat teks rusak -> catatan:', hasilRusak.catatan.length);
console.log('Alasan:', hasilRusak.alasan);

Jalankan dari folder bagian ini:

bash
node demo-penyimpanan.mjs

Output yang Dihasilkan:

text
Teks yang disimpan:
[{"id":2,"judul":"Jadwal Belajar","isi":"Latihan module ES besok malam."}]

Setelah dibuka ulang: 0 catatan
Muat ulang -> 1 catatan, alasan: null
  #2 Jadwal Belajar
Id catatan baru setelah muat: 3

Muat teks rusak -> catatan: 0
Alasan: Bukan JSON yang valid: Expected property name or '}' in JSON at position 1 (line 1 column 2)

Empat hal patut dicermati. Teks yang disimpan adalah JSON rapat satu baris — itulah wujud data di dalam localStorage nanti, termasuk id 2 yang menempel pada catatannya. Setelah dibuka ulang, daftar benar-benar kosong (0 catatan), dan muat ulang mengembalikan tepat satu catatan dengan alasan: null — siklus simpan–muat tidak mengubah apa pun. Id catatan baru menjadi 3, bukan kembali ke 1: bukti baris penghitungId di muatCatatan bekerja; tanpa sinkronisasi itu, catatan baru bisa menabrak id yang sudah ada. Terakhir, teks rusak menghasilkan 0 catatan beserta alasan lengkap — pesan SyntaxError bawaan V8 kini mencantumkan posisi kesalahannya, dan posisi itulah yang ikut terbawa ke laporan.

Pembungkus localStorage di Antarmuka #

Sisi module selesai; di antarmuka yang tersisa hanya pengantar-pengantar. Berikut index.html.txt kondisi akhir Bagian 3 — identik dengan src/index.html.txt:

html index.html.txt
<!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>
    <div class="pencarian">
      <input type="search" id="input-cari" placeholder="Cari catatan..." autocomplete="off">
    </div>
    <p id="ringkasan" class="ringkasan"></p>
    <ul id="daftar-catatan" class="daftar-catatan"></ul>
  </main>
  <script type="module">
    import {
      buatCatatan,
      ubahCatatan,
      hapusCatatan,
      daftarCatatan,
      cariCatatan,
      simpanCatatan,
      muatCatatan,
    } from './catatan.mjs';

    const KUNCI_PENYIMPANAN = 'catatan-aplikasi';

    let idSedangDiubah = null;
    let kataKunci = '';

    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 inputCari = document.querySelector('#input-cari');
    const elemenDaftar = document.querySelector('#daftar-catatan');
    const elemenPesan = document.querySelector('#pesan');
    const elemenRingkasan = document.querySelector('#ringkasan');

    // Pembungkus localStorage — kode browser, tidak ikut diuji node --test.
    function simpan() {
      window.localStorage.setItem(KUNCI_PENYIMPANAN, simpanCatatan());
    }

    function muat() {
      const hasil = muatCatatan(window.localStorage.getItem(KUNCI_PENYIMPANAN));
      if (hasil.alasan !== null) {
        console.warn('Data tersimpan dilewati:', hasil.alasan);
      }
    }

    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 = '';
      const semua = daftarCatatan();
      const tampilan = cariCatatan(semua, kataKunci);
      elemenRingkasan.textContent = kataKunci.trim() === ''
        ? `${semua.length} catatan tersimpan`
        : `${tampilan.length} dari ${semua.length} catatan cocok`;

      for (const catatan of tampilan) {
        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;
        simpan();
        render();
      } catch (galat) {
        elemenPesan.textContent = galat.message;
        elemenPesan.hidden = false;
      }
    });

    tombolBatal.addEventListener('click', () => {
      bersihkanModeEdit();
      elemenPesan.hidden = true;
    });

    inputCari.addEventListener('input', () => {
      kataKunci = inputCari.value;
      render();
    });

    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);
        simpan();
        render();
      }
    });

    muat();
    render();
  </script>
</body>
</html>

Perubahan dari Bagian 2 semuanya berkisar di sekitar penyimpanan. Mari kita bedah:

  1. Baris 34–35 (simpanCatatan, dan muatCatatan,): daftar import bertambah dua nama. Tidak ada ekspor lain yang bocor ke skrip — antarmuka tetap tidak tahu bagaimana daftar dikelola di dalam module.
  2. Baris 38 (const KUNCI_PENYIMPANAN = 'catatan-aplikasi';): localStorage menyimpan pasangan kunci-nilai, dan satu aplikasi cukup satu kunci supaya muat dan simpan selalu menyasar kotak yang sama. Kunci dinyatakan sekali sebagai konstanta di skrip module — bukan ditulis ulang di setiap pemanggilan.
  3. Baris 53–56 (function simpan()): tiga baris yang isinya jelas dari namanya — ambil string dari simpanCatatan(), antar ke window.localStorage.setItem. Awalan window. membuat asal API-nya eksplisit (keduanya menunjuk objek yang sama). Tidak ada keputusan bentuk data di sini; semua sudah selesai di module.
  4. Baris 58–63 (function muat()): baca string dengan getItem, serahkan langsung ke muatCatatan, lalu periksa hasil.alasan. Bila bukan null, laporan dituliskan lewat console.warn — data yang rusak tidak dipendam, tetapi juga tidak perlu mengganggu layar pengguna: aplikasi tetap jalan dengan daftar kosong.
  5. Baris 132 dan 164 (simpan()): dua jalur yang mengubah data — setelah membuat atau mengubah lewat form, dan setelah menghapus dari kartu — kini ditutup dengan penyimpanan. Perhatikan posisi simpan() pada baris 132: ia berada di dalam blok try, setelah logika berhasil. Catatan yang gagal validasi tidak pernah sampai ke penyimpanan.
  6. Baris 169 (muat()): dipanggil sekali saat skrip dimuat, sebelum render() pertama. Catatan lama masuk ke state module lebih dulu, lalu render() menggambarnya — pengguna melihat daftar lamanya sejak tampilan pertama.

Sembilan Uji Baru untuk Penyimpanan #

Penyimpanan mendapat tiga suite: simpanCatatan (dua uji), muatCatatan (enam uji), dan siklus data (satu uji). Impor berkas uji bertambah dua nama:

js tests/catatan.test.mjs — tambahan Bagian 3
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';

// ... kelompok uji Bagian 1 dan Bagian 2 tetap sama ...

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);
  });
});

Jalankan kembali dari folder proyek:

bash
node --test

Output yang Dihasilkan:

text
▶ buatCatatan
  ✔ membuat catatan pertama dengan id 1 (1.18626ms)
  ✔ id berlanjut untuk catatan berikutnya (0.161585ms)
  ✔ judul dan isi dipangkas spasi di kedua ujungnya (0.146206ms)
  ✔ judul yang kosong setelah dipangkas ditolak (0.372307ms)
  ✔ isi yang kosong setelah dipangkas ditolak (0.132005ms)
  ✔ judul yang melebihi 100 karakter ditolak (0.105054ms)
✔ buatCatatan (3.150712ms)
▶ ubahCatatan
  ✔ mengubah judul dan isi catatan yang dicari (0.295369ms)
  ✔ field yang tidak disebut tidak ikut berubah (0.132504ms)
  ✔ judul yang kosong pada perubahan ditolak (0.182491ms)
  ✔ mengembalikan null bila id tidak ditemukan (0.134588ms)
✔ ubahCatatan (0.985183ms)
▶ hapusCatatan
  ✔ menghapus catatan dengan id yang dicari (1.096219ms)
  ✔ mengembalikan false bila id tidak ditemukan (0.174179ms)
✔ hapusCatatan (1.410352ms)
▶ daftarCatatan
  ✔ mengembalikan salinan yang setara tetapi terpisah (0.154976ms)
✔ daftarCatatan (0.212964ms)
▶ cariCatatan
  ✔ kata kunci kosong mengembalikan seluruh catatan (0.123712ms)
  ✔ mencocokkan kata kunci pada judul (0.13142ms)
  ✔ mencocokkan kata kunci pada isi (0.102218ms)
  ✔ mencocokkan potongan kata (0.075694ms)
  ✔ pencarian tidak peka huruf besar-kecil dan spasi ujung (0.082454ms)
  ✔ semua kata harus ditemukan, boleh terpisah judul dan isi (0.064677ms)
  ✔ tanpa kecocokan mengembalikan array kosong (0.038154ms)
✔ cariCatatan (0.771903ms)
▶ simpanCatatan
  ✔ daftar kosong menjadi teks "[]" (0.063196ms)
  ✔ menghasilkan teks JSON dari seluruh catatan (0.05017ms)
✔ simpanCatatan (0.165872ms)
▶ muatCatatan
  ✔ null dikembalikan sebagai daftar kosong tanpa alasan (0.159785ms)
  ✔ mengurai teks JSON menjadi catatan tersimpan (0.103661ms)
  ✔ teks yang bukan JSON dikembalikan dengan alasan (0.179114ms)
  ✔ JSON yang bukan daftar ditolak dengan alasan (0.06933ms)
  ✔ entri yang tidak lengkap dilewati (0.07644ms)
  ✔ id berikutnya menyusul id tertinggi data yang dimuat (0.059346ms)
✔ muatCatatan (0.775949ms)
▶ siklus data
  ✔ simpan lalu muat mengembalikan catatan yang sama (0.102279ms)
✔ siklus data (0.14952ms)
ℹ tests 29
ℹ suites 8
ℹ pass 29
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 83.079863

Dua puluh sembilan uji lulus dalam delapan suite — tiga belas uji Bagian 1 dan tujuh uji Bagian 2 tetap hijau tanpa diganggu, sembilan uji baru mengunci penyimpanan. Beberapa asersi layak dicermati. Uji menghasilkan teks JSON dari seluruh catatan menuntut string JSON lengkap dengan urutannya — klaim semencol itu hanya mungkin karena id deterministik: penghitung memberi 1 dan 2 secara pasti, sehingga urutan field pun bisa dikunci. Uji teks yang bukan JSON memegang tiga jaminan sekaligus: hasilnya array kosong, alasan cocok dengan pola, dan — yang paling penting — catatan yang sudah ada sebelum muat tetap utuh; kegagalan memuat tidak boleh ikut menghapus data yang sedang dipakai. Uji entri yang tidak lengkap dilewati memasukkan empat jenis entri buruk dalam satu array — tanpa id, tanpa judul, isi saja, bahkan string polos — dan menuntut satu-satunya yang sah tetap masuk dengan bentuk yang utuh.

Ringkasan Bagian 3 #

Catatan kini bertahan lintas sesi: string JSON yang dibentuk simpanCatatan dititipkan ke localStorage oleh pembungkus tipis di antarmuka, dan muatCatatan menyambutnya kembali dengan pemeriksaan berlapis — null, JSON rusak, bukan daftar, entri tidak lengkap — semuanya terkunci dua puluh sembilan uji. Sisi fitur proyek sudah tuntas. Namun sampul catatan.mjs masih menyimpan kerapian yang bisa ditingkatkan: blok validasi yang sama tertulis di dua fungsi, dan angka 100 tertanam langsung di dua tempat. Bagian 4 memetakan seluruh uji sebagai satu gambaran, lalu merapikan kode dengan disiplin refactoring — tanpa mengubah satu pun perilaku.

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;
}