Sebuah kalkulator terlihat sederhana di mata penggunanya: tekan angka, tekan operasi, tekan angka lagi, tekan sama dengan. Di balik kesederhanaan itu ada pertanyaan desain yang harus dijawab sebelum satu baris kode pun ditulis: data apa saja yang perlu diingat program di setiap ketukan tombol, dan di berkas mana data itu dikelola. Bagian pertama proyek ini menjawab keduanya — kita merancang state kalkulator, menuliskannya sebagai module logika murni, menyiapkan kerangka antarmukanya, dan mengunci perilaku awal ini dengan uji otomatis.

Empat Berkas, Empat Peran #

Proyek ini hanya membutuhkan empat berkas kode. Setiap berkas punya satu tanggung jawab yang tidak boleh dicampur dengan yang lain:

BerkasPeran
src/logika.mjsOtak: menyimpan state, menambahkan digit, mencatat operasi. Tidak menyentuh DOM.
src/index.html.txtWajah: layar, tombol, dan skrip penghubung dari tombol ke logika.
styles/styles.cssPenampil: tata letak layar dan tombol.
tests/logika.test.mjsPenjaga: uji otomatis yang memastikan logika berperilaku sesuai kontrak.

Pemisahan ini bukan sekadar kerapian. Karena logika.mjs tidak memuat satu pun kode browser, kita bisa menjalankan dan mengujinya langsung dengan Node di terminal — tanpa membuka halaman web. Seluruh keputusan perhitungan diuji di tests/, sementara HTML dan CSS diuji mata kita di browser. Kebiasaan ini sejalan dengan prinsip separation of concerns yang dibahas pada section Architecture and Patterns.

Merancang State: Tiga Nilai yang Perlu Diingat #

Mari kita perhatikan dulu apa yang “diingat” kalkulator saat kita mengetik 12 + 3. Ada angka yang sedang diketik (12, lalu 3), ada operasi yang menunggu (+), dan ada angka yang sudah diketik sebelum operasi tersebut (12). Dari pengamatan itu, state kalkulator cukup diwakili tiga nilai:

  • angkaBaru — string angka yang sedang diketik pengguna; berupa string karena isi layar dibangun karakter demi karakter.
  • simbolOperasi — simbol operasi yang menunggu angka kedua; null bila tidak ada operasi yang menunggu.
  • angkaPertama — angka yang telah diketik sebelum operasi menunggu; berupa number agar bisa langsung dihitung.

Tabel berikut memperagakan perubahan state ketika pengguna mengetik 12, lalu menekan +, lalu mengetik 3:

KetukanangkaBarusimbolOperasiangkaPertamaLayar
(awal)'0'nullnull0
1'1'nullnull1
2'12'nullnull12
+'''+'1212
3'3''+'123

Perhatikan baris keempat: setelah + ditekan, angkaBaru menjadi string kosong ''. String kosong inilah tanda bahwa kalkulator sedang menunggu angka kedua — konvensi kecil yang akan kita pakai terus sampai akhir proyek. Kolom Layar dihitung oleh fungsi tampilanDari(state): bila angkaBaru tidak kosong, layar menampilkan apa yang sedang diketik; bila kosong, layar menampilkan angkaPertama.

Satu keputusan desain lagi: setiap fungsi logika menerima state dan mengembalikan state baru, bukan mengubah objek lama. Aliran datanya satu arah — state masuk, state baru keluar — sehingga uji bisa membandingkan hasilnya dengan pasti.

Module Logika Pertama #

Berikut logika.mjs versi pertama — berkas lengkap pada akhir Bagian 1 ini. Fungsi operasi hitung belum ada; yang ada baru pengelolaan state dan tampilan:

js logika.mjs
// logika.mjs — logika inti kalkulator, terpisah dari tampilan (DOM).
// Semua fungsi menerima dan mengembalikan state polos sehingga dapat
// diuji dengan node --test tanpa browser.

export function buatState() {
  return { angkaBaru: '0', simbolOperasi: null, angkaPertama: null };
}

export function inputDigit(state, digit) {
  if (state.angkaBaru === '0') {
    return { ...state, angkaBaru: digit };
  }
  return { ...state, angkaBaru: state.angkaBaru + digit };
}

export function pilihOperasi(state, simbol) {
  return {
    ...state,
    angkaBaru: '',
    simbolOperasi: simbol,
    angkaPertama: Number(state.angkaBaru),
  };
}

export function tampilanDari(state) {
  if (state.angkaBaru !== '') {
    return state.angkaBaru;
  }
  return String(state.angkaPertama);
}

Mari kita bedah perannya satu per satu:

  1. buatState(): mengembalikan state awal — layar menampilkan '0', belum ada operasi yang menunggu, dan belum ada angka pertama. Setiap kali kalkulator direset, kita cukup memanggil fungsi ini lagi.
  2. inputDigit(state, digit): menggabungkan satu digit ke angka yang sedang diketik. Bila angkaBaru masih '0', digit menggantikan nol itu (menekan 1 saat layar 0 menghasilkan '1', bukan '01'); selain itu, digit disambung di belakang string yang sudah ada.
  3. pilihOperasi(state, simbol): mencatat operasi yang menunggu. Angka yang sedang di layar dikonversi menjadi number lewat Number(state.angkaBaru) dan disimpan sebagai angkaPertama, sedangkan angkaBaru dikosongkan menjadi '' — tanda menunggu angka kedua.
  4. tampilanDari(state): menerjemahkan state menjadi teks untuk layar. Fungsi ini satu-satunya yang perlu dipanggil antarmuka setiap kali state berubah.

Untuk membuktikan modul ini bekerja tanpa browser, kita buat berkas demo yang menyimulasikan ketukan tombol:

// demo-state.mjs — menyimulasikan ketukan tombol tanpa browser.
import { buatState, inputDigit, pilihOperasi, tampilanDari } from './logika.mjs';

let state = buatState();
console.log('Layar awal          :', tampilanDari(state));

state = inputDigit(state, '1');
state = inputDigit(state, '2');
console.log('Setelah mengetik 12 :', tampilanDari(state));

state = pilihOperasi(state, '+');
console.log('Setelah menekan +   :', tampilanDari(state));
console.log('Isi state           :', JSON.stringify(state));

Jalankan dari folder bagian ini:

bash
node demo-state.mjs

Output yang Dihasilkan:

text
Layar awal          : 0
Setelah mengetik 12 : 12
Setelah menekan +   : 12
Isi state           : {"angkaBaru":"","simbolOperasi":"+","angkaPertama":12}

Tiga hal patut dicatat dari keluaran di atas. Layar tetap menampilkan 12 setelah + ditekan — sesuai baris keempat tabel state, karena tampilanDari menampilkan angkaPertama ketika angkaBaru kosong. Baris terakhir memperlihatkan isi state sesungguhnya setelah dirender menjadi teks oleh JSON.stringify: angkaBaru berupa string kosong, simbolOperasi berupa string '+', dan angkaPertama sudah berubah menjadi number 12 tanpa tanda kutip.

Kerangka Antarmuka: index.html #

Berkas antarmuka disimpan sebagai index.html.txt — salin ulang sebagai index.html bila ingin mencobanya. Berikut wujudnya pada akhir Bagian 1:

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>Kalkulator</title>
  <link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
  <main class="kalkulator">
    <output class="layar" id="layar">0</output>
    <div class="tombol-grid">
      <button class="tombol" data-digit="7">7</button>
      <button class="tombol" data-digit="8">8</button>
      <button class="tombol" data-digit="9">9</button>
      <button class="tombol tombol--operasi" data-operasi="/">÷</button>

      <button class="tombol" data-digit="4">4</button>
      <button class="tombol" data-digit="5">5</button>
      <button class="tombol" data-digit="6">6</button>
      <button class="tombol tombol--operasi" data-operasi="*">×</button>

      <button class="tombol" data-digit="1">1</button>
      <button class="tombol" data-digit="2">2</button>
      <button class="tombol" data-digit="3">3</button>
      <button class="tombol tombol--operasi" data-operasi="-">−</button>

      <button class="tombol tombol--hapus" data-aksi="hapus">C</button>
      <button class="tombol tombol--nol" data-digit="0">0</button>
      <button class="tombol tombol--sama-dengan" data-aksi="sama-dengan">=</button>
    </div>
  </main>
  <script type="module">
    import { buatState, inputDigit, tampilanDari } from './logika.mjs';

    const layar = document.querySelector('#layar');
    let state = buatState();

    function perbaruiLayar() {
      layar.textContent = tampilanDari(state);
    }

    document.querySelector('.tombol-grid').addEventListener('click', (event) => {
      const tombol = event.target.closest('button');
      if (tombol === null) return;

      if (tombol.dataset.digit !== undefined) {
        state = inputDigit(state, tombol.dataset.digit);
      } else if (tombol.dataset.aksi === 'hapus') {
        state = buatState();
      }
      perbaruiLayar();
    });
  </script>
</body>
</html>

Bagian markup memuat satu elemen <output id="layar"> sebagai layar dan enam belas <button> di dalam .tombol-grid. Setiap tombol membawa data attribute yang menjelaskan perannya: data-digit="7" untuk angka, data-operasi="/" untuk operasi, dan data-aksi="hapus" atau data-aksi="sama-dengan" untuk dua aksi khusus. Label tombol operasi memakai tanda tipografis (÷, ×, −), sedangkan isi data attribute memakai simbol ASCII (/, *, -) yang dikenali oleh logika — dua hal ini sengaja dipisahkan agar tampilan tetap cantik tanpa menyulitkan perhitungan.

Skrip di bagian bawah menghubungkan tombol ke logika. Mari kita bedah baris-baris pentingnya:

  1. import { buatState, inputDigit, tampilanDari } from './logika.mjs';: antarmuka hanya mengimpor tiga fungsi yang diperlukan Bagian 1. Dua fungsi lain akan menyusul di Bagian 2.
  2. let state = buatState();: seluruh skrip menjaga satu variabel state sebagai satu-satunya sumber kebenaran tampilan.
  3. event.target.closest('button'): satu listener pada .tombol-grid melayani seluruh tombol (event delegation). closest('button') menemukan tombol yang benar-benar ditekan meskipun pengguna mengklik area di sekitarnya; bila null, klik tidak mengenai tombol mana pun dan listener langsung berhenti.
  4. tombol.dataset.digit: membaca data attribute sebagai string — persis tipe yang diharapkan inputDigit.
  5. state = inputDigit(state, ...) / state = buatState(): state lama diganti state baru hasil logika, lalu perbaruiLayar() merender ulang.

Perhatikan bahwa tombol operasi dan tombol = sudah ada di markup tetapi belum punya cabang penanganan — keduanya sengaja belum berperilaku. Kita hubungkan keduanya di Bagian 2, setelah logika perhitungannya siap.

Tampilan Rapi dengan CSS #

Berkas styles/styles.css menjaga tampilan tetap sederhana namun nyata — layar gelap di atas, grid tombol di bawahnya:

css styles.css
/* styles.css — tampilan kalkulator sederhana */

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

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

.layar {
  display: block;
  min-height: 48px;
  margin-bottom: 12px;
  padding: 10px 12px;
  background-color: #111827;
  color: #f9fafb;
  font-size: 2rem;
  text-align: right;
  white-space: nowrap;
  overflow-x: auto;
  border-radius: 8px;
}

.tombol-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
}

.tombol {
  padding: 12px 0;
  font-size: 1.25rem;
  border: none;
  border-radius: 8px;
  background-color: #e5e7eb;
  cursor: pointer;
}

.tombol:hover {
  background-color: #d1d5db;
}

.tombol--operasi {
  background-color: #fde68a;
}

.tombol--sama-dengan {
  background-color: #86efac;
}

.tombol--hapus {
  background-color: #fecaca;
}

.tombol--nol {
  grid-column: span 2;
}

Tiga aturan yang paling menentukan bentuk: body memusatkan kalkulator di tengah layar dengan flexbox; .tombol-grid membagi area tombol menjadi empat kolom sama lebar dengan CSS Grid; dan .tombol--nol membentang dua kolom (grid-column: span 2) sehingga enam belas sel terbagi rapi dalam empat baris. Kelas modifier seperti .tombol--operasi memberi warna berbeda tanpa menduplikasi aturan dasar .tombol. Angka hasil perhitungan yang panjang tetap terbaca karena .layar menampilkan tanda hubung gulir horizontal (overflow-x: auto) alih-alih memotongnya.

Uji Pertama dengan node –test #

Sekarang kita kunci perilaku yang sudah ditulis. Tambahkan berikut ini ke tests/logika.test.mjs pada folder proyek — berkas uji tumbuh mengikuti pembahasan, dan kelima uji di bawah akan tetap ada sampai akhir proyek:

js tests/logika.test.mjs — kondisi bagian 1
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
  buatState,
  inputDigit,
  pilihOperasi,
  tampilanDari,
} from '../src/logika.mjs';

describe('inputDigit', () => {
  test('digit pertama menggantikan nol awal', () => {
    const state = inputDigit(buatState(), '7');
    assert.strictEqual(state.angkaBaru, '7');
  });

  test('digit berikutnya digabung menjadi angka lebih panjang', () => {
    let state = buatState();
    state = inputDigit(state, '1');
    state = inputDigit(state, '2');
    state = inputDigit(state, '5');
    assert.strictEqual(state.angkaBaru, '125');
  });
});

describe('pilihOperasi', () => {
  test('menyimpan angka pertama dan simbol operasi', () => {
    let state = inputDigit(buatState(), '8');
    state = pilihOperasi(state, '+');
    assert.strictEqual(state.angkaPertama, 8);
    assert.strictEqual(state.simbolOperasi, '+');
    assert.strictEqual(state.angkaBaru, '');
  });
});

describe('skenario mengetik', () => {
  test('layar menampilkan angka pertama saat operasi menunggu', () => {
    let state = inputDigit(buatState(), '9');
    state = pilihOperasi(state, '-');
    assert.strictEqual(tampilanDari(state), '9');
  });
});

Jalankan dari folder proyek:

bash
node --test

Output yang Dihasilkan:

text
▶ inputDigit
  ✔ digit pertama menggantikan nol awal (0.5456ms)
  ✔ digit berikutnya digabung menjadi angka lebih panjang (0.135733ms)
✔ inputDigit (1.536318ms)
▶ pilihOperasi
  ✔ menyimpan angka pertama dan simbol operasi (0.996933ms)
✔ pilihOperasi (1.137447ms)
▶ skenario mengetik
  ✔ layar menampilkan angka pertama saat operasi menunggu (0.166752ms)
✔ skenario mengetik (0.28724ms)
ℹ tests 4
ℹ suites 3
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 62.169291

Keempat uji lulus (pass 4, fail 0). Perhatikan pola pengujiannya: state dibangun langkah demi langkah seperti ketukan tombol sungguhan — buatState(), lalu inputDigit, lalu pilihOperasi — dan klaimnya diperiksa dengan assert.strictEqual. Uji “layar menampilkan angka pertama saat operasi menunggu” menjaga konvensi angkaBaru === '' yang menjadi fondasi Bagian 2. Angka durasi seperti 0.5456ms berbeda-beda setiap dijalankan karena mengukur waktu mesin Anda.

Ringkasan Bagian 1 #

Kerangka proyek telah berdiri: state tiga nilai (angkaBaru, simbolOperasi, angkaPertama) dengan konvensi string kosong sebagai tanda menunggu, module logika.mjs yang sepenuhnya bebas DOM, antarmuka HTML dengan data attribute yang rapi, CSS grid yang tersusun, dan empat uji otomatis yang lulus. Tombol operasi dan sama dengan masih diam — justru itu pekerjaan Bagian 2: memberi otak hitung pada kalkulator kita.

Berkas Sumber

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

styles.css

Unduh berkas
css
/* styles.css — tampilan kalkulator sederhana */

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

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

.layar {
  display: block;
  min-height: 48px;
  margin-bottom: 12px;
  padding: 10px 12px;
  background-color: #111827;
  color: #f9fafb;
  font-size: 2rem;
  text-align: right;
  white-space: nowrap;
  overflow-x: auto;
  border-radius: 8px;
}

.tombol-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
}

.tombol {
  padding: 12px 0;
  font-size: 1.25rem;
  border: none;
  border-radius: 8px;
  background-color: #e5e7eb;
  cursor: pointer;
}

.tombol:hover {
  background-color: #d1d5db;
}

.tombol--operasi {
  background-color: #fde68a;
}

.tombol--sama-dengan {
  background-color: #86efac;
}

.tombol--hapus {
  background-color: #fecaca;
}

.tombol--nol {
  grid-column: span 2;
}