9
Tes Pemahaman Tajwid
Tes Pemahaman Tajwid adalah latihan pilihan ganda yang soalnya diambil acak dari bank soal tajwid. Satu ronde selalu berisi QUIZ_LENGTH soal, opsi setiap soal diacak, dan skor terbaik per kategori disimpan di peramban src/lib/quiz.ts:4-51. Halaman ini menjelaskan bank soal, fungsi drawQuiz, alur satu ronde, dan pencatatan skor. Pola penyimpanan di peramban dibahas di Progres Belajar di Perangkat, sedangkan metadata halaman /tes dibahas di SEO dan Metadata.
Dua rute, satu bank soal
/tes hanya menampilkan kartu kategori. Setiap kartu menautkan /tes/{kategori}, mencetak jumlah soal lewat {quizBanks[category].length} soal, dan memasang <QuizScoreBadge category={category} /> src/app/tes/page.tsx:33-532. Rute dinamis menyerahkan quizBanks[kategori] ke QuizRunner src/app/tes/[kategori]/page.tsx:583.
Daftar kategori dibekukan saat build: generateStaticParams memetakan materiCategories dan dynamicParams = false src/app/tes/[kategori]/page.tsx:12-184. Kategori di luar daftar itu memanggil notFound() src/app/tes/[kategori]/page.tsx:32-355. Saat ini materiCategories hanya berisi "tajwid" src/lib/categories.ts:6-86.
| Rute | Komponen utama | Sumber soal |
|---|---|---|
/tes | QuizListPage | quizBanks[category].length untuk label jumlah soal |
/tes/tajwid | QuizRunner | quizBanks["tajwid"], yaitu tajwidQuiz |
Bank soal tajwid
Setiap entri QuizQuestion membawa empat opsi bertipe tuple, satu pembahasan, dan satu slug pelajaran src/lib/quiz.ts:7-147. Opsi pertama selalu jawaban benar; drawQuiz yang memindahkan posisinya. Bank dirakit di quizBanks sebagai Record<MateriCategory, QuizQuestion[]> dan baru berisi satu kategori tajwid src/lib/quiz-data/index.ts:1-78.
tajwidQuiz disusun dari materi kelas Lanjutan, dan tiap soal menautkan pertemuan yang membahasnya. Berkasnya dibagi komentar per tahap: makhraj, nun sukun dan mim sukun, mad, tebal-tipis serta qalqalah, lalu waqaf dan gharib src/lib/quiz-data/tajwid.ts:3-119.
| Field | Tipe | Peran |
|---|---|---|
question | string | teks soal, sekaligus key pada daftar jawaban salah |
options | [string, string, string, string] | empat pilihan; indeks 0 adalah jawaban benar |
explanation | string | pembahasan yang tampil setelah menjawab |
lesson | string | slug pertemuan untuk tautan /lanjutan/{lesson} |
QuizItem adalah soal yang sudah ditampilkan: options menjadi string[] dan answer menyimpan posisi jawaban benar setelah opsi diacak src/lib/quiz.ts:16-2010.
drawQuiz: satu ronde sepuluh soal
drawQuiz(bank, length = QUIZ_LENGTH, random = Math.random) mengacak bank, memotong length soal pertama, lalu mengacak indeks opsi tiap soal src/lib/quiz.ts:33-4111. Karena opsi pertama adalah jawaban benar, posisi barunya dihitung dengan order.indexOf(0) src/lib/quiz.ts:37-4012. Pengacakan memakai algoritma Fisher-Yates di atas salinan array, sehingga bank asli tidak berubah src/lib/quiz.ts:24-3113.
graph LR
bank["tajwidQuiz"] --> shuf["shuffle(bank, random)"]
shuf --> cut["slice(0, QUIZ_LENGTH)"]
cut --> opt["shuffle indeks opsi"]
opt --> ans["answer = order.indexOf(0)"]
ans --> items["QuizItem array"]
items --> runner["QuizRunner.items"]
style bank fill:#336791,color:#fff
style shuf fill:#8b5cf6,color:#fff
style cut fill:#8b5cf6,color:#fff
style opt fill:#8b5cf6,color:#fff
style ans fill:#f59e0b,color:#000
style items fill:#2d7d46,color:#fff
style runner fill:#2d7d46,color:#fff
%% fig: Jalur drawQuiz dari bank soal ke QuizItem
%% desc: Bank diacak, dipotong sepuluh soal, opsi tiap soal diacak ulang, lalu posisi jawaban benar disimpan di answer.Setiap soal hanya diambil sekali dari hasil shuffle, jadi satu ronde tidak memuat soal kembar src/lib/quiz.ts:34-3614.
Satu ronde: klik sampai skor
QuizRunner menyimpan items sebagai useState<QuizItem[] | null>(null) dan baru mengisinya di start() src/components/quiz/quiz-ui.tsx:44-5615.
⚠️
drawQuizsengaja tidak dipanggil saat render. Komentar diQuizRunnermenyatakan soal ditarik saat klik supaya markup server dan client selalu sama src/components/quiz/quiz-ui.tsx:47-5616.
Sebelum items terisi, yang tampil adalah kartu pembuka: disebutkan bahwa QUIZ_LENGTH soal diambil dari bank.length soal, dengan tombol "Mulai tes" src/components/quiz/quiz-ui.tsx:58-7417.
sequenceDiagram
actor Murid
participant Page as halaman dinamis
participant Runner as QuizRunner
participant Quiz as drawQuiz
participant Store as useQuizScores
participant Badge as QuizScoreBadge
Page->>Runner: category dan bank
Murid->>Runner: klik Mulai tes, start()
Runner->>Quiz: drawQuiz(bank)
Quiz-->>Runner: QuizItem sebanyak QUIZ_LENGTH
loop tiap soal sampai sepuluh
Runner->>Murid: pertanyaan dan empat opsi
Murid->>Runner: setPicked(i)
Runner->>Murid: pembahasan dan tautan lanjutan
Murid->>Runner: next()
end
Runner->>Store: updateScores(recordScore(saved, category, benar))
Store-->>Badge: best, last, attempts
%% fig: Satu ronde tes dari pengambilan soal sampai penyimpanan skor
%% desc: Klik Mulai tes menarik sepuluh soal, setiap jawaban memunculkan pembahasan, dan ronde terakhir menulis skor lewat recordScore.Saat satu opsi dipilih, picked menyimpan indeksnya, tombol opsi dinonaktifkan, dan pembahasan muncul bersama tautan "Buka materinya" ke /lanjutan/{item.lesson} yang dibuka di tab baru src/components/quiz/quiz-ui.tsx:210-22318. next() menambahkan { item, picked } ke answers; ketika jumlahnya sudah sama dengan items.length, ia memanggil updateScores dengan hasil recordScore(...) src/components/quiz/quiz-ui.tsx:141-14919.
Hasil dan materi yang perlu diulang
Nilai akhir adalah correct * 10, sehingga rentangnya 0 sampai 100 untuk sepuluh soal, disertai correct dari items.length jawaban benar src/components/quiz/quiz-ui.tsx:86-8920. Kalimat penutupnya dipilih verdict(correct) src/components/quiz/quiz-ui.tsx:35-4021:
Nilai correct | Kalimat verdict |
|---|---|
correct === QUIZ_LENGTH | "Sempurna! Semua jawaban benar." |
correct >= 8 | "Bagus sekali, pemahamanmu sudah kuat." |
correct >= 6 | "Cukup baik. Ulangi materi yang masih keliru." |
| lainnya | "Tetap semangat. Pelajari lagi materinya, lalu coba kembali." |
Jawaban yang salah dikumpulkan di bagian "Yang perlu dipelajari lagi": opsi yang dipilih ditampilkan dicoret, jawaban benar ditebalkan, lalu pembahasan dan tautan /lanjutan/{item.lesson} ditampilkan src/components/quiz/quiz-ui.tsx:107-13122.
Skor terbaik per kategori
recordScore mengembalikan objek baru: best adalah Math.max(previous?.best ?? 0, correct), last berisi hasil ronde terakhir, dan attempts bertambah satu src/lib/quiz.ts:67-7823.
Field QuizScore | Cara recordScore mengisi | Tampilan di QuizScoreBadge |
|---|---|---|
best | nilai tertinggi antara skor lama dan correct | score.best * 10 |
last | correct dari ronde terakhir | score.last * 10 |
attempts | jumlah percobaan sebelumnya ditambah satu | {score.attempts}× mencoba |
Tempat penyimpanannya dibuat sekali di tingkat modul: createLocalStore(QUIZ_SCORES_KEY, parseScores, emptyScores) src/components/quiz/quiz-ui.tsx:1924, dengan key localStorage bernama tes-pemahaman src/lib/quiz.ts:525. createLocalStore merender nilai empty di server, mengisinya setelah hydration, dan menyiarkan perubahan ke komponen lain di halaman yang sama src/components/use-local-store.ts:9-2226.
parseScores membuang entri yang tidak lengkap atau bukan bilangan bulat non-negatif, serta mengembalikan emptyScores bila string kosong, bukan JSON, atau bukan objek src/lib/quiz.ts:46-6527.
stateDiagram-v2
[*] --> Kosong
Kosong --> Tersimpan: "ronde selesai, recordScore dipanggil"
Tersimpan --> Tersimpan: "ronde baru, best = max, attempts + 1"
Tersimpan --> Kosong: "parseScores menolak nilai rusak"
classDef kosong fill:#dc2626,color:#fff
classDef simpan fill:#2d7d46,color:#fff
class Kosong kosong
class Tersimpan simpan
%% fig: Siklus skor per kategori
%% desc: Kategori tanpa skor berubah menjadi tersimpan setelah ronde pertama, ronde berikutnya hanya menaikkan attempts, dan nilai rusak dibuang kembali ke kosong.QuizScoreBadge mencetak score.best * 10, score.last * 10, dan jumlah percobaan, lalu tidak merender apa pun bila kategori belum pernah diuji src/components/quiz/quiz-ui.tsx:22-3328.
⚠️ Skor melekat pada
localStorageperamban yang dipakai, bukan pada akun. Bila nilai tidak ada atau rusak,parseScoresmengembalikanemptyScores, sehingga kartu tes tampil tanpa riwayat nilai src/lib/quiz.ts:46-4829.
Yang dijaga test
| Berkas uji | Yang dijaga |
|---|---|
| tests/quiz.test.ts:15-2430 | panjang bank minimal QUIZ_LENGTH * 5, soal unik, empat opsi unik, explanation tidak kosong, dan lesson ada di daftar lessons tajwid |
| tests/quiz.test.ts:28-3431 | setiap pertemuan kelas Lanjutan punya soal, kecuali praktik-mengajar |
| tests/quiz.test.ts:36-5032 | drawQuiz menghasilkan QUIZ_LENGTH soal berbeda, dan item.options[item.answer] sama dengan original.options[0] |
| tests/quiz.test.ts:52-6533 | recordScore({}, "tajwid", 7) lalu recordScore(first, "tajwid", 5) menghasilkan { best: 7, last: 5, attempts: 2 }, dan parseScores menolak nilai rusak |