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:
| Lapis | Tempatnya | Tugasnya | Diuji node --test? |
|---|---|---|---|
| Serialisasi dan validasi | src/catatan.mjs | mengubah daftar menjadi string JSON dan sebaliknya; memeriksa data yang dimuat berlapis-lapis | ya — fungsi murni, bebas DOM |
Pembungkus localStorage | src/index.html.txt | setItem dan getItem dengan satu kunci | tidak — 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:
// 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:
- Baris 84–86 (
export function simpanCatatan()): tugasnya satu — mengembalikanJSON.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 memuatnumberdanstring, dua jenis yangJSON.stringifypertahankan tanpa kejutan. - Baris 88–94 (
function catatanSah(item)): syarat satu catatan lengkap —idberupanumber,juduldanisiberupastring. Penulisanitem?.idmemakai optional chaining: bilaitemberupanullatauundefined(bisa saja terjadi pada entri aneh di data luar), ekspresi berhenti dan menghasilkanundefinedtanpa melempar galat, lalu pemeriksaantypeofmenolaknya dengan tenang. Helper ini tidak diekspor — detail internal module. - Baris 97–99 (
if (teksJson === null)):localStorage.getItemmengembalikannullketika kunci belum pernah diisi — keadaan pemakaian pertama, bukan galat. Maka fungsi mengembalikan daftar kosong denganalasan: null: tidak ada yang perlu dilaporkan, cukup mulai dari nol. - Baris 100–105 (
try…catchsekitarJSON.parse): teks yang bukan JSON membuatJSON.parsemelemparSyntaxError. Alih-alih membiarkan program berhenti,catchmengubahnya menjadi hasil berisialasan: "Bukan JSON yang valid: ..."— pesan galat bawaan ikut disertakan supaya penyebabnya bisa dilacak. Pemanggil menerima laporan, bukan program yang berhenti. - Baris 106–108 (
if (!Array.isArray(data))): JSON yang valid belum tentu benar bentuknya. Teks{"judul":"bukan array"}lolosJSON.parsetetapi bukan daftar catatan — ditolak dengan alasan yang lebih spesifik agar pemiliknya tahu apa yang salah. - Baris 109 (
data.filter(catatanSah).map(...)): lapis pemeriksaan terakhir — entri yang tidak lengkap (tanpaid, tanpajudul, atau bukan object) dilewati, entri yang sah disalin satu per satu dengan spread sehingga data luar tidak memegang referensi langsung ke state. - Baris 110–111 (
daftar = hasil;danpenghitungId = ...): state module diganti hasil yang sudah bersih, lalu penghitung id disamakan dengan id tertinggi lewatreducedanMath.max. Tanpa baris kedua, catatan baru setelah muat bisa menabrak id lama — dua catatan ber-id sama adalah resep kebingungan saat mengedit dan menghapus. - Baris 112 (
return { catatan: daftarCatatan(), alasan: null }): hasil selalu object dua field, dancatatandiambil daridaftarCatatan()— 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:
node demo-penyimpanan.mjsOutput yang Dihasilkan:
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:
<!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:
- Baris 34–35 (
simpanCatatan,danmuatCatatan,): daftarimportbertambah dua nama. Tidak ada ekspor lain yang bocor ke skrip — antarmuka tetap tidak tahu bagaimanadaftardikelola di dalam module. - Baris 38 (
const KUNCI_PENYIMPANAN = 'catatan-aplikasi';):localStoragemenyimpan 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. - Baris 53–56 (
function simpan()): tiga baris yang isinya jelas dari namanya — ambil string darisimpanCatatan(), antar kewindow.localStorage.setItem. Awalanwindow.membuat asal API-nya eksplisit (keduanya menunjuk objek yang sama). Tidak ada keputusan bentuk data di sini; semua sudah selesai di module. - Baris 58–63 (
function muat()): baca string dengangetItem, serahkan langsung kemuatCatatan, lalu periksahasil.alasan. Bila bukannull, laporan dituliskan lewatconsole.warn— data yang rusak tidak dipendam, tetapi juga tidak perlu mengganggu layar pengguna: aplikasi tetap jalan dengan daftar kosong. - 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 posisisimpan()pada baris 132: ia berada di dalam bloktry, setelah logika berhasil. Catatan yang gagal validasi tidak pernah sampai ke penyimpanan. - Baris 169 (
muat()): dipanggil sekali saat skrip dimuat, sebelumrender()pertama. Catatan lama masuk ke state module lebih dulu, lalurender()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:
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:
node --testOutput yang Dihasilkan:
▶ 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.079863Dua 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/* 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;
}