Di akhir Bagian 2, aplikasi cuaca kita sudah bisa menampilkan kartu yang rapi — tetapi hanya pada skenario yang paling mudah. Selama fetch sedang berjalan, kartu masih menampilkan hasil pencarian sebelumnya tanpa petunjuk apa pun bahwa data baru sedang diambil; pengguna bisa menyangka aplikasi macet. Saat server menjawab 404, yang muncul di kartu adalah pesan teknis Gagal memuat data: Server menjawab 404 untuk kota "Bulan" — kalimat yang jujur bagi programmer, tetapi tidak memberi tahu pengguna apa yang sebaiknya dilakukan berikutnya.

Bagian ini menata keduanya dengan satu keputusan desain: antarmuka dimodelkan sebagai state machine sederhana. Seluruh kemungkinan posisi aplikasi didaftar secara eksplisit, dan perpindahan antarposisi hanya boleh terjadi lewat satu pintu — sebuah fungsi murni bernama keadaanBerikut. Karena pintunya satu dan fungsinya murni, setiap perpindahan bisa diuji di terminal tanpa browser, sama seperti formatCuaca pada Bagian 2. Hasilnya, pengguna tidak pernah menatap layar kosong tanpa penjelasan: setiap keadaan punya teksnya sendiri.

Empat Keadaan Satu Mesin #

Antarmuka cuaca kita hanya punya empat posisi, dan semuanya sudah kita alami sebagai pengguna aplikasi sungguhan:

KeadaanArtinyaYang ditampilkan kartu
kosongbelum ada pencarianajakan mengetik nama kota
memuatpermintaan sedang berjalanteks “Memuat data cuaca untuk …”
suksesdata diterima dan validkartu cuaca hasil formatCuaca
galatpermintaan gagalpesan ramah hasil pesanRamah

Perpindahan antarkeadaan mengikuti alur yang tegas: keadaan kosong, sukses, maupun galat semuanya hanya bisa pindah lewat keadaan memuat ketika pengguna mencari lagi.

Diagram di atas hanyalah gambaran dari kontrak yang akan kita tulis sebagai kode: tidak ada jalur dari sukses langsung ke galat, dan tidak ada keadaan kelima. Mesinnya sengaja kecil supaya mudah diperiksa mata.

Setiap keadaan direpresentasikan sebagai satu objek polos dengan empat properti — nama, kota, data, dan pesan. Titik awalnya dibuat oleh keadaanAwal:

js cuaca.mjs — titik awal state machine
export function keadaanAwal() {
  return { nama: 'kosong', kota: null, data: null, pesan: null };
}

Properti kota, data, dan pesan sengaja diisi null dari awal, bukan dibiarkan tidak ada. Dengan begitu setiap keadaan punya bentuk yang sama persis — kode yang membaca keadaan tidak perlu bertanya “apakah properti ini ada”, cukup memeriksa isinya.

keadaanBerikut: Satu Pintu Perubahan #

Inti state machine ini adalah satu fungsi murni. Fungsi tersebut menerima keadaan lama dan satu aksi, lalu mengembalikan keadaan baru:

js cuaca.mjs — satu pintu perubahan keadaan
// Satu-satunya pintu perubahan keadaan: terima keadaan lama + aksi,
// kembalikan keadaan baru tanpa mengubah yang lama.
export function keadaanBerikut(keadaan, aksi) {
  switch (aksi.tipe) {
    case 'cari':
      return { nama: 'memuat', kota: aksi.kota, data: null, pesan: null };
    case 'sukses':
      return { nama: 'sukses', kota: keadaan.kota, data: aksi.data, pesan: null };
    case 'galat':
      return { nama: 'galat', kota: keadaan.kota, data: null, pesan: aksi.pesan };
    default:
      return keadaan;
  }
}

Mari kita bedah baris demi baris:

  1. Baris 3 (export function keadaanBerikut(keadaan, aksi)): parameter pertama adalah keadaan sekarang, parameter kedua adalah aksi berbentuk objek dengan properti tipe. Fungsi ini tidak menyimpan apa pun — seluruh informasi masuk lewat parameter, keluar lewat return.
  2. Baris 4 (switch (aksi.tipe)): aksi dibedakan lewat properti tipe, bukan lewat nama fungsi yang berbeda-beda. Satu pintu berarti riwayat perubahan keadaan selalu bisa direkonstruksi dari daftar aksi.
  3. Baris 6 (return { nama: 'memuat', kota: aksi.kota, data: null, pesan: null }): aksi cari menghasilkan keadaan memuat. Nama kota baru disimpan, sementara data dan pesan dari pencarian sebelumnya dikosongkan — hasil lama tidak boleh menempel di layar selagi data baru dimuat.
  4. Baris 8 (return { nama: 'sukses', kota: keadaan.kota, data: aksi.data, pesan: null }): aksi sukses menyimpan data yang baru tiba. Perhatikan kota diambil dari keadaan lama, bukan dari aksi: data yang tiba memang milik pencarian yang sedang berjalan, sehingga kotanya ikut dari keadaan tersebut.
  5. Baris 10 (return { nama: 'galat', kota: keadaan.kota, data: null, pesan: aksi.pesan }): aksi galat menyimpan pesan untuk pengguna dan mengosongkan data, sehingga kartu tidak menampilkan data lama bersanding dengan pesan kegagalan.
  6. Baris 12 (return keadaan;): aksi yang tidak dikenal mengembalikan keadaan lama tanpa diubah. Keadaan yang tidak sah tidak pernah bisa tercipta hanya karena ada typo pada tipe.

pesanRamah: Menerjemahkan Galat untuk Pengguna #

Objek ApiError dari Bagian 1 membawa informasi yang lengkap bagi programmer — message dan status — tetapi kalimatnya ditulis untuk log, bukan untuk pengguna. Kalimat Server menjawab 404 untuk kota "Bulan" memakai istilah HTTP dan tidak menyebut apa yang bisa dicoba. Karena itu cuaca.mjs menyiapkan satu fungsi penerjemah:

js cuaca.mjs — penerjemah galat menjadi pesan ramah
// Menerjemahkan galat mentah menjadi pesan yang enak dibaca pengguna.
export function pesanRamah(galat) {
  if (galat instanceof ApiError && galat.status === 404) {
    return 'Kota yang dicari tidak ada dalam data server. Periksa kembali ejaannya.';
  }
  if (galat instanceof ApiError) {
    return `Server sedang bermasalah (kode ${galat.status}). Coba lagi beberapa saat.`;
  }
  return 'Tidak dapat menghubungi server. Periksa koneksi, lalu coba lagi.';
}

Mari kita bedah jalannya galat melalui fungsi ini:

  1. Baris 3 (if (galat instanceof ApiError && galat.status === 404)): galat dari server dengan kode 404 berarti ejaan kota yang salah — kesalahan pengguna, dan pengguna yang bisa memperbaikinya. Pesannya menunjukkan tindakan konkret: periksa ejaan.
  2. Baris 6–7 (if (galat instanceof ApiError) ...): galat server lain (500, misalnya) bukan kesalahan pengguna. Pesannya tetap menyebut kode statusnya sebagai petunjuk pelaporan, tetapi menyarankan menunggu dan mencoba lagi.
  3. Baris 9 (return 'Tidak dapat menghubungi server. ...'): galat yang bukan ApiError — TypeError: fetch failed saat jaringan putus, misalnya — jatuh ke sini. Pesannya tidak menyebut detail teknis seperti nama class galat, karena pengguna tidak dapat memperbaiki detail tersebut.

Fungsi ini murni: objek galat masuk, string keluar. Tidak ada DOM, sehingga ketiga kalimatnya bisa dikunci uji di terminal.

cuaca.mjs pada Akhir Bagian 3 #

Berikut cuaca.mjs lengkap pada kondisi akhir Bagian ini. Dibanding versi Bagian 2, berkas ini menambah bagian state machine: keadaanAwal, keadaanBerikut, dan pesanRamah:

js cuaca.mjs
// cuaca.mjs — logika inti aplikasi cuaca, terpisah dari tampilan (DOM).
// Tidak ada satu pun kode browser di berkas ini, sehingga seluruh
// perilakunya bisa diuji dengan node --test tanpa membuka halaman web.

// --- Mengambil data: fetch, response.ok, dan ApiError ---

// Galat khusus untuk kegagalan yang berasal dari sisi API/server.
export class ApiError extends Error {
  constructor(pesan, status) {
    super(pesan);
    this.name = 'ApiError';
    this.status = status;
  }
}

// Server tiruan lokal (node:http) hidup di dalam berkas demo; kita tidak
// memanggil API internet apa pun di course ini. Port 18080 adalah
// kesepakatan proyek: server tiruan menunggu di situ.
const URL_DASAR = 'http://localhost:18080';

// Mengambil data cuaca satu kota dari server.
export async function muatCuaca(kota, { baseUrl = URL_DASAR } = {}) {
  const url = `${baseUrl}/cuaca?kota=${encodeURIComponent(kota)}`;
  const respons = await fetch(url);

  if (!respons.ok) {
    throw new ApiError(`Server menjawab ${respons.status} untuk kota "${kota}"`, respons.status);
  }

  const data = await respons.json();
  if (typeof data.kota !== 'string' || typeof data.suhu !== 'number') {
    throw new ApiError('Format data cuaca tidak dikenal', respons.status);
  }
  return data;
}

// --- Menampilkan data: formatter murni ---

// Satu instance formatter dipakai ulang: menyusunnya cukup mahal.
const formatSuhu = new Intl.NumberFormat('id-ID', {
  style: 'unit',
  unit: 'celsius',
  maximumFractionDigits: 1,
});

// Style 'percent' mengharapkan pecahan: 78% ditulis 0,78.
const formatPersen = new Intl.NumberFormat('id-ID', {
  style: 'percent',
  maximumFractionDigits: 0,
});

// Mengubah data mentah dari server menjadi objek tampilan yang siap
// dirender: semua nilainya sudah berupa teks berformat.
export function formatCuaca(data) {
  return {
    judul: `Cuaca di ${data.kota}`,
    suhu: formatSuhu.format(data.suhu),
    kelembapan: formatPersen.format(data.kelembapan / 100),
    deskripsi: data.deskripsi,
  };
}

// Merangkai objek tampilan menjadi satu teks multi baris — berguna untuk
// demo di terminal maupun pratinjau cepat.
export function keTeks(tampilan) {
  return [
    tampilan.judul,
    `Suhu      : ${tampilan.suhu}`,
    `Kelembapan: ${tampilan.kelembapan}`,
    `Kondisi   : ${tampilan.deskripsi}`,
  ].join('\n');
}

// --- Keadaan antarmuka: state machine sederhana ---

export function keadaanAwal() {
  return { nama: 'kosong', kota: null, data: null, pesan: null };
}

// Satu-satunya pintu perubahan keadaan: terima keadaan lama + aksi,
// kembalikan keadaan baru tanpa mengubah yang lama.
export function keadaanBerikut(keadaan, aksi) {
  switch (aksi.tipe) {
    case 'cari':
      return { nama: 'memuat', kota: aksi.kota, data: null, pesan: null };
    case 'sukses':
      return { nama: 'sukses', kota: keadaan.kota, data: aksi.data, pesan: null };
    case 'galat':
      return { nama: 'galat', kota: keadaan.kota, data: null, pesan: aksi.pesan };
    default:
      return keadaan;
  }
}

// Menerjemahkan galat mentah menjadi pesan yang enak dibaca pengguna.
export function pesanRamah(galat) {
  if (galat instanceof ApiError && galat.status === 404) {
    return 'Kota yang dicari tidak ada dalam data server. Periksa kembali ejaannya.';
  }
  if (galat instanceof ApiError) {
    return `Server sedang bermasalah (kode ${galat.status}). Coba lagi beberapa saat.`;
  }
  return 'Tidak dapat menghubungi server. Periksa koneksi, lalu coba lagi.';
}

Demo: Siklus Hidup Satu Pencarian #

State machine tidak membutuhkan jaringan untuk didemonstrasikan — aksi bisa disuntikkan langsung, persis seperti data pada demo Bagian 2. Berkas demo-keadaan.mjs memutar satu skenario lengkap: sukses, lalu galat, lalu sukses lagi:

// demo-keadaan.mjs — menyimulasikan siklus hidup satu pencarian cuaca.
// Semua data disuntikkan langsung ke state machine, jadi demo ini tidak
// membutuhkan jaringan maupun server sama sekali.
import { ApiError, keadaanAwal, keadaanBerikut, pesanRamah } from './cuaca.mjs';

function cetak(label, keadaan) {
  const data = keadaan.data === null ? 'null' : 'ada';
  const pesan = keadaan.pesan === null ? '-' : `"${keadaan.pesan}"`;
  console.log(`${label} -> ${keadaan.nama} | kota=${keadaan.kota} | data=${data} | pesan=${pesan}`);
}

let keadaan = keadaanAwal();
cetak('awal', keadaan);

keadaan = keadaanBerikut(keadaan, { tipe: 'cari', kota: 'Bandung' });
cetak('cari "Bandung"', keadaan);

keadaan = keadaanBerikut(keadaan, {
  tipe: 'sukses',
  data: { kota: 'Bandung', suhu: 24.6, kelembapan: 78, deskripsi: 'Berawan sebagian' },
});
cetak('respons sukses', keadaan);

keadaan = keadaanBerikut(keadaan, { tipe: 'cari', kota: 'Bulan' });
cetak('cari "Bulan"', keadaan);

const galat404 = new ApiError('Server menjawab 404 untuk kota "Bulan"', 404);
keadaan = keadaanBerikut(keadaan, { tipe: 'galat', pesan: pesanRamah(galat404) });
cetak('respons galat', keadaan);

keadaan = keadaanBerikut(keadaan, { tipe: 'cari', kota: 'Surabaya' });
cetak('cari "Surabaya"', keadaan);

keadaan = keadaanBerikut(keadaan, {
  tipe: 'sukses',
  data: { kota: 'Surabaya', suhu: 31.2, kelembapan: 65, deskripsi: 'Cerah berawan' },
});
cetak('respons sukses', keadaan);

Jalankan dari folder bagian ini:

bash
node demo-keadaan.mjs

Output yang Dihasilkan:

text
awal -> kosong | kota=null | data=null | pesan=-
cari "Bandung" -> memuat | kota=Bandung | data=null | pesan=-
respons sukses -> sukses | kota=Bandung | data=ada | pesan=-
cari "Bulan" -> memuat | kota=Bulan | data=null | pesan=-
respons galat -> galat | kota=Bulan | data=null | pesan="Kota yang dicari tidak ada dalam data server. Periksa kembali ejaannya."
cari "Surabaya" -> memuat | kota=Surabaya | data=null | pesan=-
respons sukses -> sukses | kota=Surabaya | data=ada | pesan=-

Tujuh baris keluaran menceritakan tujuh posisi mesin. Perhatikan dua hal. Pertama, setiap aksi cari mengosongkan data dan pesan — tidak ada jejak pencarian lama yang menempel. Kedua, pada respons galat, pesan yang tersimpan bukan pesan teknis Server menjawab 404 ... milik ApiError, melainkan hasil terjemahan pesanRamah — versi yang menyebut tindakan yang bisa dilakukan pengguna.

Antarmuka: Merender Sesuai Keadaan #

Dengan state machine siap, tugas skrip antarmuka berkurang menjadi dua: meneruskan aksi, lalu menggambar keadaan yang berlaku. index.html.txt versi Bagian 3:

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 Cuaca</title>
  <link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
  <main class="panel">
    <h1>Cuaca Kota</h1>
    <form id="formulir-cari">
      <input
        id="kolom-kota"
        name="kota"
        type="text"
        placeholder="Contoh: Bandung"
        autocomplete="off"
      >
      <button type="submit">Lihat</button>
    </form>
    <section id="kartu-cuaca" class="kartu">
      <p>Ketik nama kota untuk melihat cuacanya.</p>
    </section>
  </main>
  <script type="module">
    import {
      formatCuaca,
      keadaanAwal,
      keadaanBerikut,
      muatCuaca,
      pesanRamah,
    } from './cuaca.mjs';

    const formulir = document.querySelector('#formulir-cari');
    const kartu = document.querySelector('#kartu-cuaca');

    let keadaan = keadaanAwal();

    function renderKeadaan() {
      kartu.textContent = '';

      if (keadaan.nama === 'kosong') {
        const teks = document.createElement('p');
        teks.textContent = 'Ketik nama kota untuk melihat cuacanya.';
        kartu.append(teks);
        return;
      }

      if (keadaan.nama === 'memuat') {
        const teks = document.createElement('p');
        teks.className = 'status status--memuat';
        teks.textContent = `Memuat data cuaca untuk ${keadaan.kota}...`;
        kartu.append(teks);
        return;
      }

      if (keadaan.nama === 'galat') {
        const teks = document.createElement('p');
        teks.className = 'status status--galat';
        teks.textContent = keadaan.pesan;
        kartu.append(teks);
        return;
      }

      const tampilan = formatCuaca(keadaan.data);
      const judul = document.createElement('h2');
      judul.textContent = tampilan.judul;

      const daftar = document.createElement('ul');
      for (const [label, nilai] of [
        ['Suhu', tampilan.suhu],
        ['Kelembapan', tampilan.kelembapan],
        ['Kondisi', tampilan.deskripsi],
      ]) {
        const item = document.createElement('li');
        item.textContent = `${label}: ${nilai}`;
        daftar.append(item);
      }
      kartu.append(judul, daftar);
    }

    renderKeadaan();

    formulir.addEventListener('submit', async (event) => {
      event.preventDefault();
      const kota = new FormData(formulir).get('kota').trim();
      if (kota === '') return;

      keadaan = keadaanBerikut(keadaan, { tipe: 'cari', kota });
      renderKeadaan();

      try {
        const data = await muatCuaca(kota);
        keadaan = keadaanBerikut(keadaan, { tipe: 'sukses', data });
      } catch (galat) {
        keadaan = keadaanBerikut(keadaan, { tipe: 'galat', pesan: pesanRamah(galat) });
      }
      renderKeadaan();
    });
  </script>
</body>
</html>

Bagian yang benar-benar baru ada di dua tempat. Pertama, fungsi renderKeadaan yang menggambar satu keadaan saja:

  1. Baris 38 (let keadaan = keadaanAwal();): satu-satunya variabel keadaan di skrip ini. Seluruh tampilan adalah fungsi dari variabel ini — tidak ada kartu yang menyimpan statusnya sendiri.
  2. Baris 40–41 (function renderKeadaan() { kartu.textContent = ''; ... }): setiap kali dirender, kartu dikosongkan lebih dulu. Menggambar ulang dari nol lebih aman daripada menambal tampilan lama, karena tidak ada cabang yang lupa membersihkan sisa sebelumnya.
  3. Baris 50–53 (cabang memuat): teks memuat menyebut nama kotanya — Memuat data cuaca untuk Bandung... — bukan sekadar “Memuat…”. Kalimat spesifik menenangkan lebih baik daripada teks generik yang berkedip tanpa makna.
  4. Baris 58–61 (cabang galat): isi kartu adalah keadaan.pesan — pesan ramah hasil pesanRamah, bukan galat.message yang teknis. Detail teknis tetap tersedia di objek galat bila kita ingin mencatatnya ke console.
  5. Baris 66 (const tampilan = formatCuaca(keadaan.data);): keadaan sukses tidak dicabang dengan if karena sudah dijamin nama === 'sukses' setelah tiga cabang di atasnya lolos. Data diambil dari keadaan, bukan dari variabel lain.

Kedua, handler submit yang kini hanya mengirim aksi:

  1. Baris 90–91 (keadaan = keadaanBerikut(keadaan, { tipe: 'cari', kota }); renderKeadaan();): keadaan dipindah ke memuat dan langsung digambar — pengguna melihat teks memuat sebelum permintaan selesai, bukan sesudahnya.
  2. Baris 95 (keadaan = keadaanBerikut(keadaan, { tipe: 'sukses', data });): respons yang tiba dikemas sebagai aksi, bukan langsung ditulis ke kartu. Semua perubahan lewat keadaanBerikut sebagaimana mestinya.
  3. Baris 97 (keadaan = keadaanBerikut(keadaan, { tipe: 'galat', pesan: pesanRamah(galat) });): galat diterjemahkan di sini, tepat sebelum dikirim sebagai aksi. Skrip tidak pernah menyimpan objek galat ke dalam keadaan.
  4. Baris 99 (renderKeadaan();): digambar sekali di akhir, untuk jalur sukses maupun galat. Kedua jalur bertemu di titik yang sama, sehingga tidak ada keadaan yang terlupa digambar.

Dua warna status menemani teks memuat dan galat. Tambahan pada styles.css hanya dua aturan ini — berkas lengkapnya ikut tersimpan di bundle:

css styles.css — tambahan Bagian 3
.status--memuat {
  color: #b45309;
}

.status--galat {
  color: #b91c1c;
  font-weight: 600;
}

Menguji Transisi dan Pesan #

Karena state machine murni, ujinya tidak membutuhkan server sama sekali. Dua kelompok uji berikut ditambahkan ke tests/cuaca.test.mjs:

js tests/cuaca.test.mjs — tambahan Bagian 3
// Import diperluas: keadaanAwal, keadaanBerikut, dan pesanRamah ikut dimuat.
// Server tiruan serta kelompok uji muatCuaca dan formatCuaca tetap sama ...

describe('keadaanBerikut', () => {
  test('aksi cari memindahkan keadaan awal ke memuat', () => {
    const keadaan = keadaanBerikut(keadaanAwal(), { tipe: 'cari', kota: 'Bandung' });
    assert.deepStrictEqual(keadaan, { nama: 'memuat', kota: 'Bandung', data: null, pesan: null });
  });

  test('aksi sukses menyimpan data dan mengosongkan pesan', () => {
    let keadaan = keadaanBerikut(keadaanAwal(), { tipe: 'cari', kota: 'Bandung' });
    keadaan = keadaanBerikut(keadaan, { tipe: 'sukses', data: DATA_CUACA.bandung });
    assert.deepStrictEqual(keadaan, {
      nama: 'sukses',
      kota: 'Bandung',
      data: DATA_CUACA.bandung,
      pesan: null,
    });
  });

  test('aksi galat menyimpan pesan dan membuang data', () => {
    let keadaan = keadaanBerikut(keadaanAwal(), { tipe: 'cari', kota: 'Bulan' });
    keadaan = keadaanBerikut(keadaan, { tipe: 'galat', pesan: 'Kota tidak ditemukan.' });
    assert.deepStrictEqual(keadaan, {
      nama: 'galat',
      kota: 'Bulan',
      data: null,
      pesan: 'Kota tidak ditemukan.',
    });
  });

  test('pencarian baru dari keadaan sukses kembali ke memuat', () => {
    let keadaan = keadaanBerikut(keadaanAwal(), { tipe: 'cari', kota: 'Bandung' });
    keadaan = keadaanBerikut(keadaan, { tipe: 'sukses', data: DATA_CUACA.bandung });
    keadaan = keadaanBerikut(keadaan, { tipe: 'cari', kota: 'Surabaya' });
    assert.strictEqual(keadaan.nama, 'memuat');
    assert.strictEqual(keadaan.data, null);
  });

  test('aksi yang tidak dikenal tidak mengubah keadaan', () => {
    const keadaan = keadaanAwal();
    assert.strictEqual(keadaanBerikut(keadaan, { tipe: 'ulang' }), keadaan);
  });
});

describe('pesanRamah', () => {
  test('ApiError 404 menjadi pesan kota tidak ditemukan', () => {
    const pesan = pesanRamah(new ApiError('Server menjawab 404 untuk kota "Bulan"', 404));
    assert.match(pesan, /tidak ada dalam data server/);
  });

  test('ApiError selain 404 menjadi pesan server bermasalah', () => {
    const pesan = pesanRamah(new ApiError('Server menjawab 500', 500));
    assert.match(pesan, /Server sedang bermasalah \(kode 500\)/);
  });

  test('galat jaringan menjadi pesan koneksi', () => {
    const pesan = pesanRamah(new TypeError('fetch failed'));
    assert.match(pesan, /Tidak dapat menghubungi server/);
  });
});

Jalankan kembali dari folder proyek:

bash
node --test

Output yang Dihasilkan:

text
▶ muatCuaca
  ✔ mengembalikan data JSON untuk kota yang tersedia (26.649183ms)
  ✔ melempar ApiError berstatus 404 untuk kota yang tidak ditemukan (3.541378ms)
  ✔ mengodekan nama kota menjadi URL yang aman (1.889049ms)
✔ muatCuaca (34.41132ms)
▶ formatCuaca
  ✔ memformat suhu, kelembapan, dan deskripsi (0.485355ms)
  ✔ membatasi suhu ke satu tempat desimal (0.231994ms)
  ✔ menyusun baris teks siap tampilkan (0.232846ms)
✔ formatCuaca (1.210299ms)
▶ keadaanBerikut
  ✔ aksi cari memindahkan keadaan awal ke memuat (0.238489ms)
  ✔ aksi sukses menyimpan data dan mengosongkan pesan (0.139636ms)
  ✔ aksi galat menyimpan pesan dan membuang data (0.159757ms)
  ✔ pencarian baru dari keadaan sukses kembali ke memuat (0.148984ms)
  ✔ aksi yang tidak dikenal tidak mengubah keadaan (0.091676ms)
✔ keadaanBerikut (1.051202ms)
▶ pesanRamah
  ✔ ApiError 404 menjadi pesan kota tidak ditemukan (0.234336ms)
  ✔ ApiError selain 404 menjadi pesan server bermasalah (0.103904ms)
  ✔ galat jaringan menjadi pesan koneksi (0.0641ms)
✔ pesanRamah (0.533796ms)
ℹ tests 14
ℹ suites 4
ℹ pass 14
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 177.354111

Empat belas uji lulus dari empat kelompok. Uji “pencarian baru dari keadaan sukses kembali ke memuat” menegaskan aturan penting yang mudah dilupakan implementasi: hasil lama harus benar-benar dikosongkan (data === null) ketika pencarian berikutnya dimulai, bukan sekadar berganti nama keadaan. Uji pesanRamah memakai assert.match dengan pola regex alih-alih membandingkan string utuh — inti yang dikunci adalah maknanya (sebut ejaan, sebut kode, sebut koneksi), bukan tata letak kalimat yang boleh berubah. Angka durasi berbeda-beda di setiap mesin.

Ringkasan Bagian 3 #

Antarmuka kini punya model yang jujur: empat keadaan, satu pintu perubahan, dan pesan galat yang berbicara dengan pengguna, bukan dengan log server. Selama menunggu, pengguna melihat nama kotanya di teks memuat; saat gagal, pengguna menerima saran yang bisa dijalankan. Semua transisi terkunci empat belas uji.

Satu celah masih tersisa, dan celahnya muncul justru karena aplikasi kini responsif: pengguna yang mengetik cepat bisa mengirim banyak permintaan berturut-turut, dan jawaban yang lambat bisa tiba belakangan lalu menimpa jawaban yang lebih baru. Bagian 4 menutup celah itu dengan cache, debounce, dan AbortController.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

styles.css

Unduh berkas
css
/* styles.css — tampilan aplikasi cuaca */

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #e0f2fe;
  font-family: system-ui, sans-serif;
}

.panel {
  width: 300px;
  padding: 20px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.panel h1 {
  margin: 0 0 12px;
  font-size: 1.25rem;
  color: #0c4a6e;
}

#formulir-cari {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

#kolom-kota {
  flex: 1;
  padding: 8px 10px;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  font-size: 1rem;
}

#formulir-cari button {
  padding: 8px 14px;
  border: none;
  border-radius: 8px;
  background-color: #0284c7;
  color: #ffffff;
  font-size: 1rem;
  cursor: pointer;
}

#formulir-cari button:hover {
  background-color: #0369a1;
}

.kartu {
  min-height: 80px;
  padding: 12px;
  background-color: #f8fafc;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  font-size: 0.95rem;
  color: #334155;
  white-space: pre-wrap;
}

.kartu h2 {
  margin: 0 0 8px;
  font-size: 1.1rem;
  color: #0c4a6e;
}

.kartu ul {
  margin: 0;
  padding-left: 18px;
}

.kartu li {
  margin-bottom: 4px;
}

.status--memuat {
  color: #b45309;
}

.status--galat {
  color: #b91c1c;
  font-weight: 600;
}