Pada akhir Bagian 1, kalkulator kita bisa menampilkan angka tetapi belum bisa menghitung: tombol +, −, ×, ÷, dan = masih diam meskipun sudah terpasang di markup. Bagian ini memberinya otak hitung. Kita akan menambahkan empat operasi aritmetika dalam satu peta fungsi, menentukan kapan sebuah operasi dievaluasi lewat kebijakan rantai tunggal, dan menyelesaikan satu pertanyaan yang sering diabaikan: bagaimana menampilkan hasil 1 / 3 di layar yang hanya selebar beberapa digit.
Wujud Baru logika.mjs #
Berikut logika.mjs lengkap pada akhir Bagian 2. Bandingkan dengan versi Bagian 1: ada empat tambahan — peta OPERASI, konstanta JUMLAH_DESIMAL, fungsi internal formatHasil, dan dua fungsi baru hitung serta samakan — sementara pilihOperasi diperluas dan tampilanDari disempurnakan:
// logika.mjs — logika inti kalkulator, terpisah dari tampilan (DOM).
// Semua fungsi menerima dan mengembalikan state polos sehingga dapat
// diuji dengan node --test tanpa browser.
// Peta operasi: simbol -> fungsi dua parameter.
const OPERASI = new Map([
['+', (a, b) => a + b],
['-', (a, b) => a - b],
['*', (a, b) => a * b],
['/', (a, b) => a / b],
]);
// Jumlah desimal yang dipertahankan sebelum hasil dibulatkan.
const JUMLAH_DESIMAL = 10;
export function buatState() {
return { angkaBaru: '0', simbolOperasi: null, angkaPertama: null };
}
function formatHasil(angka) {
return String(Number(angka.toFixed(JUMLAH_DESIMAL)));
}
export function inputDigit(state, digit) {
if (state.angkaBaru === '0') {
return { ...state, angkaBaru: digit };
}
return { ...state, angkaBaru: state.angkaBaru + digit };
}
export function pilihOperasi(state, simbol) {
const angkaSaatIni = Number(state.angkaBaru);
if (state.simbolOperasi === null) {
return { angkaBaru: '', simbolOperasi: simbol, angkaPertama: angkaSaatIni };
}
// Rantai sudah berjalan: selesaikan dulu operasi sebelumnya.
const hasil = hitung(state.angkaPertama, state.simbolOperasi, angkaSaatIni);
return { angkaBaru: '', simbolOperasi: simbol, angkaPertama: hasil };
}
export function samakan(state) {
if (state.simbolOperasi === null) {
return state;
}
const angkaKedua = Number(state.angkaBaru);
const hasil = hitung(state.angkaPertama, state.simbolOperasi, angkaKedua);
return { angkaBaru: formatHasil(hasil), simbolOperasi: null, angkaPertama: null };
}
export function tampilanDari(state) {
if (state.angkaBaru !== '') {
return state.angkaBaru;
}
return formatHasil(state.angkaPertama);
}
export function hitung(angkaPertama, simbol, angkaKedua) {
const operasi = OPERASI.get(simbol);
if (operasi === undefined) {
throw new Error(`Simbol operasi tidak dikenal: ${simbol}`);
}
return operasi(angkaPertama, angkaKedua);
}Untuk melihat semuanya bekerja tanpa browser, kita gunakan berkas demo yang menyimulasikan beberapa sesi mengetik sekaligus:
// demo-operasi.mjs — mencoba operasi, rantai, dan pembulatan dari terminal.
import {
buatState,
hitung,
inputDigit,
pilihOperasi,
samakan,
tampilanDari,
} from './logika.mjs';
console.log('hitung(12, "+", 7) :', hitung(12, '+', 7));
console.log('hitung(12, "-", 7) :', hitung(12, '-', 7));
console.log('hitung(12, "*", 7) :', hitung(12, '*', 7));
console.log('hitung(12, "/", 7) :', hitung(12, '/', 7));
let state = buatState();
state = inputDigit(state, '2');
state = pilihOperasi(state, '+');
state = inputDigit(state, '3');
state = pilihOperasi(state, '*');
console.log('Layar setelah 2 + 3 *:', tampilanDari(state));
state = inputDigit(state, '4');
state = samakan(state);
console.log('Layar setelah = :', tampilanDari(state));
state = buatState();
state = inputDigit(state, '1');
state = pilihOperasi(state, '/');
state = inputDigit(state, '3');
state = samakan(state);
console.log('Layar setelah 1 / 3 =:', tampilanDari(state));
// Noise desimal biner, sebelum dibulatkan formatHasil.
console.log('0.1 + 0.2 mentah :', 0.1 + 0.2);
console.log('1 / 49 * 49 mentah :', 1 / 49 * 49);
state = buatState();
state = inputDigit(state, '1');
state = pilihOperasi(state, '/');
state = inputDigit(state, '4');
state = inputDigit(state, '9');
state = pilihOperasi(state, '*');
state = inputDigit(state, '4');
state = inputDigit(state, '9');
state = samakan(state);
console.log('Layar 1 / 49 * 49 = :', tampilanDari(state));Jalankan dari folder bagian ini:
node demo-operasi.mjsOutput yang Dihasilkan:
hitung(12, "+", 7) : 19
hitung(12, "-", 7) : 5
hitung(12, "*", 7) : 84
hitung(12, "/", 7) : 1.7142857142857142
Layar setelah 2 + 3 *: 5
Layar setelah = : 20
Layar setelah 1 / 3 =: 0.3333333333
0.1 + 0.2 mentah : 0.30000000000000004
1 / 49 * 49 mentah : 0.9999999999999999
Layar 1 / 49 * 49 = : 1Empat baris pertama adalah jawaban hitung untuk keempat operasi; sisanya adalah layar kalkulator pada beberapa titik sesi mengetik. Kita bedah mekanismenya per bagian pada sub-bab berikut.
Empat Operasi dalam Satu Map #
Alih-alih menulis empat fungsi terpisah atau rantai if panjang, keempat operasi dikumpulkan dalam satu Map bernama OPERASI. Kuncinya simbol yang dikenali tombol; nilainya arrow function dua parameter yang benar-benar menghitung:
const OPERASI = new Map([
['+', (a, b) => a + b],
['-', (a, b) => a - b],
['*', (a, b) => a * b],
['/', (a, b) => a / b],
]);Fungsi hitung menjadi pintu satu-satunya untuk memakai peta ini:
- Baris 2 (
const operasi = OPERASI.get(simbol);): mencari fungsi operasi berdasarkan simbol. Bila simbolnya'+', yang didapat adalah fungsi(a, b) => a + b. - Baris 3–5 (
if (operasi === undefined) ...): simbol yang tidak ada di peta — misalnya'^'— menghasilkanundefined, dan kita melemparErroralih-alih membiarkan pemanggilan fungsiundefinedterjadi. Pesannya menyebut simbol pelakunya sehingga mudah dilacak. - Baris 6 (
return operasi(angkaPertama, angkaKedua);): fungsi operasi dipanggil dengan dua angka, dan hasilnya dikembalikan sebagai number.
Empat baris pertama keluaran demo membuktikan keempat jalur ini: 19, 5, 84, dan 1.7142857142857142. Perhatikan nilai terakhir — pembagian di JavaScript jarang menghasilkan bilangan bulat, dan betapa panjangnya desimal yang dibawa number. Itu justru bahan utama sub-bab pembulatan di bawah.
Rantai Operasi Tunggal: Evaluasi Kiri ke Kanan #
Keputusan desain terpenting di Bagian 2 ada di pilihOperasi. Kalkulator kita hanya mengizinkan satu operasi menunggu pada satu waktu. Ketika pengguna menekan operator baru sementara operasi sebelumnya masih menunggu, operasi lama diselesaikan lebih dulu:
export function pilihOperasi(state, simbol) {
const angkaSaatIni = Number(state.angkaBaru);
if (state.simbolOperasi === null) {
return { angkaBaru: '', simbolOperasi: simbol, angkaPertama: angkaSaatIni };
}
// Rantai sudah berjalan: selesaikan dulu operasi sebelumnya.
const hasil = hitung(state.angkaPertama, state.simbolOperasi, angkaSaatIni);
return { angkaBaru: '', simbolOperasi: simbol, angkaPertama: hasil };
}- Baris 2: angka yang sedang di layar dikonversi menjadi number. Di sinilah string hasil ketikan akhirnya “menjadi angka sungguhan”.
- Baris 3–5: bila belum ada operasi yang menunggu (
simbolOperasi === null), cukup simpan angka saat ini sebagaiangkaPertamadan catat simbolnya. StateangkaBarudikosongkan — kalkulator menunggu angka kedua. - Baris 6–8: bila operasi sebelumnya masih menunggu,
hitungdipanggil untuk menyelesaikannya terlebih dahulu. Hasilnya menjadiangkaPertamayang baru, dan operasi yang baru ditekan menunggu giliran berikutnya.
Fungsi samakan memakai mekanisme yang sama untuk tombol = — menyelesaikan operasi yang menunggu, lalu mengosongkan seluruh rantai:
export function samakan(state) {
if (state.simbolOperasi === null) {
return state;
}
const angkaKedua = Number(state.angkaBaru);
const hasil = hitung(state.angkaPertama, state.simbolOperasi, angkaKedua);
return { angkaBaru: formatHasil(hasil), simbolOperasi: null, angkaPertama: null };
}Cabang pertama menjawab kasus yang jarang dipikirkan: apa yang terjadi bila = ditekan padahal tidak ada operasi yang menunggu — misalnya tepat setelah program dibuka, atau tepat setelah = sebelumnya? Kalkulator kita memilih perilaku paling sopan: state dikembalikan tanpa perubahan, layar tetap menampilkan angka yang ada. Inilah alasan uji “tanpa operasi, state kembali tanpa perubahan” di bawah memakai assert.strictEqual(samakan(state), state) — membandingkan identitas objek yang dikembalikan, bukan sekadar isinya.
Tabel berikut melacak sesi 2 + 3 × 4 dari awal sampai akhir:
| Ketukan | Yang dilakukan logika | Layar |
|---|---|---|
2 | angkaBaru menjadi '2' | 2 |
+ | angkaPertama = 2, simbolOperasi = '+' | 2 |
3 | angkaBaru menjadi '3' | 3 |
× | Operasi lama diselesaikan: hitung(2, '+', 3) → 5 menjadi angkaPertama | 5 |
4 | angkaBaru menjadi '4' | 4 |
= | hitung(5, '*', 4) → 20 ditampilkan, rantai dikosongkan | 20 |
Dua baris keluaran demo — Layar setelah 2 + 3 *: 5 dan Layar setelah = : 20 — persis mengikuti tabel ini. Layar menampilkan 5 di tengah rantai karena tampilanDari menampilkan angkaPertama saat menunggu angka kedua.
Pembulatan Hasil dengan Hati-hati #
Baris Layar setelah 1 / 3 =: 0.3333333333 memperlihatkan keputusan pembulatan kita. Bandingkan dengan dua baris sebelumnya: secara mentah, 0.1 + 0.2 menghasilkan 0.30000000000000004 dan 1 / 49 * 49 menghasilkan 0.9999999999999999 — padahal jawaban matematikanya 0.3 dan 1. Penyebabnya bukan bug di kode kita: number JavaScript mengikuti standar IEEE-754 berbasis biner, dan pecahan seperti 0.1 tidak dapat direpresentasikan dengan tepat di sistem biner. Sisa representasi itu muncul sebagai “noise” di sekitar digit ke-15 atau ke-16.
Karena itu formatHasil membersihkan noise sebelum hasil ditampilkan:
function formatHasil(angka) {
return String(Number(angka.toFixed(JUMLAH_DESIMAL)));
}Satu baris ini memuat tiga langkah yang layak dibedah:
angka.toFixed(JUMLAH_DESIMAL): membulatkan ke 10 tempat desimal dan menghasilkan string —0.9999999999999999menjadi'1.0000000000'.Number(...): mengembalikan string menjadi number, sekaligus memangkas nol di belakang —1.0000000000menjadi1.String(...): menyiapkan teks final untuk layar.
Mengapa 10 desimal, bukan 2? Pembulatan dua desimal bergaya uang merusak presisi hitungan. Dengan menengah yang dibulatkan ke dua desimal, 1 / 49 menjadi 0.02, dan 0.02 × 49 menghasilkan 0.98 — jauh dari jawaban sebenarnya. Dengan 10 desimal, noise biner yang muncul di digit ke-15 ke atas hilang, sementara presisi harian tetap utuh: keluaran demo menunjukkan 1 / 49 * 49 tampil sebagai 1, dan 1 / 3 tampil sebagai 0.3333333333.
Keputusan ini juga punya harga yang kita terima secara sadar: pecahan yang panjang dipotong di tempat desimal kesepuluh — 1 / 7 yang mentahnya 0.14285714285714285 tampil sebagai 0.1428571429. Untuk kalkulator sehari-hari, trade-off ini jauh lebih baik daripada menampilkan lima belas digit berisikan noise. Angka yang sangat besar malah tampil dalam notasi eksponen standar JavaScript, misalnya 1e+21.
Tombol Operasi Akhirnya Hidup #
Di antarmuka, markup tidak berubah sama sekali dari Bagian 1 — yang berubah hanya skrip penghubungnya. Dua cabang baru meneruskan tombol operasi dan tombol = ke fungsi yang baru saja kita tulis:
<!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, pilihOperasi, samakan, 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.operasi !== undefined) {
state = pilihOperasi(state, tombol.dataset.operasi);
} else if (tombol.dataset.aksi === 'sama-dengan') {
state = samakan(state);
} else if (tombol.dataset.aksi === 'hapus') {
state = buatState();
}
perbaruiLayar();
});
</script>
</body>
</html>Dibanding versi Bagian 1, ada dua perubahan kecil. Baris import kini mengambil pilihOperasi dan samakan dari logika. Rantai if ... else if di dalam listener menambah dua cabang: data-operasi diteruskan ke pilihOperasi(state, tombol.dataset.operasi), dan data-aksi="sama-dengan" diteruskan ke samakan(state). Setiap cabang mengganti state dengan state baru lalu perbaruiLayar() merender ulang — pola aliran satu arah yang sama persis dengan Bagian 1, hanya pilihan fungsinya yang bertambah.
Menguji Setiap Operasi #
Logika baru dikunci uji berikut di tests/logika.test.mjs. Impornya kini mengambil hitung dan samakan, dan tiga kelompok baru ditambahkan:
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
buatState,
hitung,
inputDigit,
pilihOperasi,
samakan,
tampilanDari,
} from '../src/logika.mjs';
// ... kelompok inputDigit dan uji Bagian 1 tetap sama ...
describe('pilihOperasi', () => {
// ... uji Bagian 1 tetap sama ...
test('menyelesaikan rantai sebelumnya dari kiri ke kanan', () => {
let state = inputDigit(buatState(), '2');
state = pilihOperasi(state, '+');
state = inputDigit(state, '3');
state = pilihOperasi(state, '*');
assert.strictEqual(state.angkaPertama, 5);
assert.strictEqual(state.simbolOperasi, '*');
});
});
describe('hitung', () => {
test('menjalankan empat operasi aritmetika', () => {
assert.strictEqual(hitung(12, '+', 7), 19);
assert.strictEqual(hitung(12, '-', 7), 5);
assert.strictEqual(hitung(12, '*', 7), 84);
assert.strictEqual(hitung(12, '/', 7), 12 / 7);
});
test('simbol yang tidak dikenal ditolak', () => {
assert.throws(() => hitung(2, '^', 3), /tidak dikenal/);
});
});
describe('samakan', () => {
test('menampilkan hasil dan mengosongkan rantai', () => {
let state = inputDigit(buatState(), '7');
state = pilihOperasi(state, '*');
state = inputDigit(state, '8');
state = samakan(state);
assert.strictEqual(state.angkaBaru, '56');
assert.strictEqual(state.simbolOperasi, null);
assert.strictEqual(state.angkaPertama, null);
});
test('tanpa operasi, state kembali tanpa perubahan', () => {
const state = inputDigit(buatState(), '9');
assert.strictEqual(samakan(state), state);
});
test('pembulatan membersihkan noise desimal biner', () => {
// 1 / 49 * 49 tanpa pembulatan menghasilkan 0.9999999999999999.
const state = { angkaBaru: '49', simbolOperasi: '*', angkaPertama: 1 / 49 };
const hasil = samakan(state);
assert.strictEqual(hasil.angkaBaru, '1');
});
});
describe('skenario mengetik', () => {
// ... uji Bagian 1 tetap sama ...
test('alur 12 + 7 menampilkan 19', () => {
let state = buatState();
state = inputDigit(state, '1');
state = inputDigit(state, '2');
state = pilihOperasi(state, '+');
state = inputDigit(state, '7');
state = samakan(state);
assert.strictEqual(tampilanDari(state), '19');
});
test('rantai 2 + 3 * 4 dievaluasi menjadi 20', () => {
let state = inputDigit(buatState(), '2');
state = pilihOperasi(state, '+');
state = inputDigit(state, '3');
state = pilihOperasi(state, '*');
state = inputDigit(state, '4');
state = samakan(state);
assert.strictEqual(tampilanDari(state), '20');
});
});Jalankan kembali dari folder proyek:
node --testOutput yang Dihasilkan:
▶ inputDigit
✔ digit pertama menggantikan nol awal (4.222073ms)
✔ digit berikutnya digabung menjadi angka lebih panjang (0.150521ms)
✔ inputDigit (5.279423ms)
▶ pilihOperasi
✔ menyimpan angka pertama dan simbol operasi (0.160441ms)
✔ menyelesaikan rantai sebelumnya dari kiri ke kanan (0.119957ms)
✔ pilihOperasi (0.467684ms)
▶ hitung
✔ menjalankan empat operasi aritmetika (0.178742ms)
✔ simbol yang tidak dikenal ditolak (0.397614ms)
✔ hitung (0.766176ms)
▶ samakan
✔ menampilkan hasil dan mengosongkan rantai (0.272879ms)
✔ tanpa operasi, state kembali tanpa perubahan (0.099944ms)
✔ pembulatan membersihkan noise desimal biner (0.113722ms)
✔ samakan (0.734717ms)
▶ skenario mengetik
✔ alur 12 + 7 menampilkan 19 (0.313867ms)
✔ rantai 2 + 3 * 4 dievaluasi menjadi 20 (0.141853ms)
✔ layar menampilkan angka pertama saat operasi menunggu (0.081131ms)
✔ skenario mengetik (0.67355ms)
ℹ tests 12
ℹ suites 5
ℹ pass 12
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 70.648137Dua belas uji lulus. Uji pembulatan sengaja menyusun state secara langsung — { angkaPertama: 1 / 49, angkaBaru: '49', simbolOperasi: '*' } — alih-alih mengetik digit demi digit: state kalkulator hanyalah objek polos, dan menyusunnya manual memungkinkan kita menjangkau nilai pecahan yang tidak bisa diketik lewat tombol. Uji “simbol yang tidak dikenal ditolak” memakai bentuk assert.throws dengan RegExp — pola yang dibahas pada chapter Assertions.
Ringkasan Bagian 2 #
Kalkulator kita kini benar-benar menghitung: empat operasi dalam satu peta Map, satu operasi menunggu pada satu waktu dengan evaluasi kiri ke kanan, = yang menyelesaikan rantai tanpa merusak state bila tidak ada yang menunggu, dan formatHasil yang membersihkan noise biner dengan pembulatan 10 desimal. Dua belas uji menjaga semua keputusan itu. Masih ada satu cacat yang tersisa — coba tekan 5, ÷, 0, = dan lihat apa yang tampil — dan itulah pekerjaan Bagian 3.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles/styles.css
Unduh berkas/* 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;
}