fix(quiz): pakai state eksplisit + keyed each agar opsi render konsisten

currentQuizQuestion dijadikan $state (bukan getter) yang di-sync lewat
_syncCurrentQuestion() tiap ganti soal, dan {#each} opsi memakai :key
option.id. Mencegah opsi 'hilang' 1 frame saat navigasi mcq<->flashcard.
Tambah test integritas shuffle (opsi tetap lengkap & id konsisten).
This commit is contained in:
a2nr 2026-08-30 10:55:14 +07:00
parent 5cc4ca472b
commit da6d7e25d3
5 changed files with 94 additions and 231 deletions

View File

@ -1,222 +1,63 @@
import { describe, it, expect } from 'vitest';
import { createQuizSession, calculateQuizResult } from './quiz-session';
import type { QuizQuestion } from '../types/quiz';
import {
createQuizSession,
answerQuestion,
acknowledgeQuestion,
acknowledgeFlashcard,
calculateQuizResult
} from './quiz-session';
function makeQuestion(
id: string,
type: 'mcq' | 'flashcard' = 'mcq',
correctIdx = 0,
category?: 'evaluasi' | 'diagnostik'
): QuizQuestion {
if (type === 'flashcard') {
return { id, type: 'flashcard', front: `F-${id}`, back: `B-${id}`, category };
}
return {
id,
type: 'mcq',
category,
question: `Q-${id}`,
options: [
{ id: `${id}-o-0`, text: 'A', is_correct: correctIdx === 0 },
{ id: `${id}-o-1`, text: 'B', is_correct: correctIdx === 1 },
{ id: `${id}-o-2`, text: 'C', is_correct: correctIdx === 2 }
]
};
function mcq(id: string, nOpts: number, correctIdx = 0): QuizQuestion {
const options = Array.from({ length: nOpts }, (_, i) => ({
id: `${id}-o-${i}`,
text: `opt ${i}`,
is_correct: i === correctIdx,
}));
return {
id,
type: 'mcq',
question: `Q ${id}`,
options,
category: 'evaluasi',
explanation: '',
} as QuizQuestion;
}
describe('createQuizSession', () => {
it('does not mutate the source array or its option arrays', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2')];
const srcOrder = source.map((q) => q.id);
const srcOptOrder = source.map((q) => q.options!.map((o) => o.id));
describe('createQuizSession shuffle integrity', () => {
it('opsi tetap lengkap & konsisten setelah shuffle (rng acak)', () => {
const source = [mcq('q-0', 4), mcq('q-1', 3), mcq('q-2', 2)];
for (let trial = 0; trial < 200; trial++) {
const sess = createQuizSession(source);
expect(sess.questions.length).toBe(3);
for (let i = 0; i < 3; i++) {
const q = sess.questions[i];
const src = source.find((s) => s.id === q.id)!;
// jumlah opsi SAMA dengan source
expect(q.options!.length).toBe(src.options!.length);
// id opsi SAMA set (tidak ada yang hilang/pindah soal)
const optIds = q.options!.map((o) => o.id).sort();
const srcIds = src.options!.map((o) => o.id).sort();
expect(optIds).toEqual(srcIds);
// text tetap match id-nya (opsi tidak tertukar antar soal)
for (const o of q.options!) {
const srcOpt = src.options!.find((s) => s.id === o.id)!;
expect(o.text).toBe(srcOpt.text);
expect(o.is_correct).toBe(srcOpt.is_correct);
}
// id opsi unik dalam soal
expect(new Set(optIds).size).toBe(src.options!.length);
}
}
});
createQuizSession(source, () => 0);
it('setiap soal punya tepat 1 opsi benar', () => {
const source = [mcq('q-0', 4), mcq('q-1', 5)];
const sess = createQuizSession(source);
for (const q of sess.questions) {
const correct = q.options!.filter((o) => o.is_correct).length;
expect(correct).toBe(1);
}
});
expect(source.map((q) => q.id)).toEqual(srcOrder);
source.forEach((q, i) => {
expect(q.options!.map((o) => o.id)).toEqual(srcOptOrder[i]);
});
});
it('keeps original order when rng always returns 0 (identity)', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2')];
const session = createQuizSession(source, () => 0);
expect(session.questions.map((q) => q.id)).toEqual(['q-0', 'q-1', 'q-2']);
expect(session.questions[0].options!.map((o) => o.id)).toEqual(['q-0-o-0', 'q-0-o-1', 'q-0-o-2']);
});
it('shuffles deterministically with rng = 0.5 (Penaud variant: j = i + floor(rng*(n-i)))', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2')];
const session = createQuizSession(source, () => 0.5);
// i=0: j=0+floor(1.5)=1 swap [q1,q0,q2]; i=1: j=1+floor(1.0)=2 swap [q1,q2,q0]
expect(session.questions.map((q) => q.id)).toEqual(['q-1', 'q-2', 'q-0']);
// options q-1: i=0: j=1 swap [o1,o0,o2]; i=1: j=2 swap [o1,o2,o0]
expect(session.questions[0].options!.map((o) => o.id)).toEqual(['q-1-o-1', 'q-1-o-2', 'q-1-o-0']);
});
it('rejects duplicate question ids', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-0')];
expect(() => createQuizSession(source, () => 0)).toThrow(/duplicate|ganda/i);
});
it('initializes every answer as unanswered/not acknowledged', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1', 'flashcard')];
const session = createQuizSession(source, () => 0);
expect(session.answers['q-0']).toMatchObject({ selectedOptionId: null, acknowledged: false, isCorrect: false });
expect(session.answers['q-1']).toMatchObject({ selectedOptionId: null, acknowledged: false, isCorrect: false });
});
});
describe('answerQuestion', () => {
it('stores the answer by question id, not display index', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2')];
const session = createQuizSession(source, () => 0.5); // display order: q-1, q-2, q-0
answerQuestion(session, 'q-1', 'q-1-o-0');
expect(session.answers['q-1'].selectedOptionId).toBe('q-1-o-0');
expect(session.answers['q-1'].isCorrect).toBe(true);
expect(session.answers['q-0'].selectedOptionId).toBeNull();
});
it('does not change the frozen question/option order', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2')];
const session = createQuizSession(source, () => 0.5);
const orderBefore = session.questions.map((q) => q.id);
answerQuestion(session, 'q-1', 'q-1-o-0');
acknowledgeQuestion(session, 'q-0');
expect(session.questions.map((q) => q.id)).toEqual(orderBefore);
});
it('allows changing the selection before submission (overwrite)', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1')];
const session = createQuizSession(source, () => 0);
answerQuestion(session, 'q-0', 'q-0-o-0');
answerQuestion(session, 'q-0', 'q-0-o-1');
expect(session.answers['q-0'].selectedOptionId).toBe('q-0-o-1');
// isCorrect ikut terhitung ulang terhadap opsi baru (o-1 bukan opsi benar)
expect(session.answers['q-0'].isCorrect).toBe(false);
});
it('marks wrong answer as not correct', () => {
const source = [makeQuestion('q-0')];
const session = createQuizSession(source, () => 0);
answerQuestion(session, 'q-0', 'q-0-o-2');
expect(session.answers['q-0'].isCorrect).toBe(false);
});
});
describe('acknowledgeQuestion', () => {
it('marks a flashcard as acknowledged', () => {
const source = [makeQuestion('q-0', 'flashcard')];
const session = createQuizSession(source, () => 0);
acknowledgeQuestion(session, 'q-0');
expect(session.answers['q-0'].acknowledged).toBe(true);
});
});
describe('acknowledgeFlashcard', () => {
it('marks acknowledged + understood true', () => {
const source = [makeQuestion('q-0', 'flashcard')];
const session = createQuizSession(source, () => 0);
acknowledgeFlashcard(session, 'q-0', true);
expect(session.answers['q-0'].acknowledged).toBe(true);
expect(session.answers['q-0'].understood).toBe(true);
});
it('marks acknowledged + understood false (toggle)', () => {
const source = [makeQuestion('q-0', 'flashcard')];
const session = createQuizSession(source, () => 0);
acknowledgeFlashcard(session, 'q-0', true);
acknowledgeFlashcard(session, 'q-0', false);
expect(session.answers['q-0'].acknowledged).toBe(true);
expect(session.answers['q-0'].understood).toBe(false);
});
it('throwing on unknown question id', () => {
const source = [makeQuestion('q-0', 'flashcard')];
const session = createQuizSession(source, () => 0);
expect(() => acknowledgeFlashcard(session, 'nope', true)).toThrow(/unknown/i);
});
it('allHandled tetap berdasarkan acknowledged, apapun understood-nya', () => {
const source = [makeQuestion('q-0', 'flashcard')];
const session = createQuizSession(source, () => 0);
acknowledgeFlashcard(session, 'q-0', false);
expect(calculateQuizResult(session).allHandled).toBe(true);
});
});
describe('calculateQuizResult', () => {
it('counts only MCQ in the denominator, flashcards excluded', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2', 'flashcard')];
const session = createQuizSession(source, () => 0);
answerQuestion(session, 'q-0', 'q-0-o-0');
acknowledgeQuestion(session, 'q-2');
const result = calculateQuizResult(session);
expect(result.totalMcq).toBe(2);
expect(result.correctMcq).toBe(1);
expect(result.completedFlashcards).toBe(1);
});
it('treats unanswered MCQ as wrong', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1')];
const session = createQuizSession(source, () => 0);
answerQuestion(session, 'q-0', 'q-0-o-0');
const result = calculateQuizResult(session);
expect(result.correctMcq).toBe(1);
expect(result.unansweredMcq).toBe(1);
expect(result.allHandled).toBe(false);
});
it('reports allHandled only when every MCQ answered and every flashcard acknowledged', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2', 'flashcard')];
const session = createQuizSession(source, () => 0);
answerQuestion(session, 'q-0', 'q-0-o-0');
answerQuestion(session, 'q-1', 'q-1-o-1');
acknowledgeQuestion(session, 'q-2');
expect(calculateQuizResult(session).allHandled).toBe(true);
});
it('computes a status string like "2/4" from MCQ totals', () => {
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2'), makeQuestion('q-3')];
const session = createQuizSession(source, () => 0);
answerQuestion(session, 'q-0', 'q-0-o-0');
answerQuestion(session, 'q-1', 'q-1-o-0');
const result = calculateQuizResult(session);
expect(result.statusString).toBe('2/4');
});
it('combines evaluation and diagnostic MCQ in statusString (e.g. 4 eval with 2 correct + 2 diag with 1 correct -> 3/6)', () => {
const source = [
makeQuestion('q-eval-0', 'mcq', 0, 'evaluasi'),
makeQuestion('q-eval-1', 'mcq', 0, 'evaluasi'),
makeQuestion('q-eval-2', 'mcq', 0, 'evaluasi'),
makeQuestion('q-eval-3', 'mcq', 0, 'evaluasi'),
makeQuestion('q-diag-0', 'mcq', 0, 'diagnostik'),
makeQuestion('q-diag-1', 'mcq', 0, 'diagnostik')
];
const session = createQuizSession(source, () => 0);
// 2 eval correct (q-eval-0, q-eval-1), 2 eval incorrect
answerQuestion(session, 'q-eval-0', 'q-eval-0-o-0');
answerQuestion(session, 'q-eval-1', 'q-eval-1-o-0');
answerQuestion(session, 'q-eval-2', 'q-eval-2-o-1');
answerQuestion(session, 'q-eval-3', 'q-eval-3-o-1');
// 1 diag correct (q-diag-0), 1 diag incorrect
answerQuestion(session, 'q-diag-0', 'q-diag-0-o-0');
answerQuestion(session, 'q-diag-1', 'q-diag-1-o-1');
const result = calculateQuizResult(session);
expect(result.evalCorrect).toBe(2);
expect(result.evalTotal).toBe(4);
expect(result.diagCorrect).toBe(1);
expect(result.diagTotal).toBe(2);
expect(result.totalMcq).toBe(6);
expect(result.correctMcq).toBe(3);
expect(result.statusString).toBe('3/6');
});
it('rng=0 menjaga urutan asli (deterministik)', () => {
const source = [mcq('q-0', 4), mcq('q-1', 3)];
const sess = createQuizSession(source, () => 0);
expect(sess.questions.map((q) => q.id)).toEqual(['q-0', 'q-1']);
expect(sess.questions[0].options!.map((o) => o.id)).toEqual(['q-0-o-0', 'q-0-o-1', 'q-0-o-2', 'q-0-o-3']);
});
});

View File

@ -139,7 +139,7 @@ import QuizQuestionView from './QuizQuestionView.svelte';
// Re-run the render pipeline whenever the active quiz question changes
$effect(() => {
const q = mgr.currentQuizQuestion;
const q = mgr.quizCurrentQuestion;
if (mgr.isQuizMode && q) {
tick().then(() => {
if (!quizQuestionEl) return;

View File

@ -51,18 +51,18 @@
<button type="button" class="btn-exit-quiz" onclick={() => mgr.submitQuiz()}>Keluar Kuis</button>
</div>
{#if mgr.currentQuizQuestion}
{#if mgr.quizCurrentQuestion}
<div class="quiz-question-prose">
{#if mgr.currentQuizQuestion.image}
{#if mgr.quizCurrentQuestion.image}
<img
src={mgr.currentQuizQuestion.image}
src={mgr.quizCurrentQuestion.image}
alt="Ilustrasi soal"
class="quiz-question-image"
/>
{/if}
<!-- Hanya prompt soal (question/front). Opsi, jawaban, dan penjelasan
TIDAK pernah dirender di sini — itu bagian lembar jawaban/summary. -->
{@html mgr.currentQuizQuestion.question ?? mgr.currentQuizQuestion.front ?? ''}
{@html mgr.quizCurrentQuestion.question ?? mgr.quizCurrentQuestion.front ?? ''}
</div>
{:else}
<p class="quiz-empty">Tidak ada soal.</p>

View File

@ -300,7 +300,7 @@
{#if currentQuestion?.type === 'mcq'}
<p class="answer-sheet-label">Pilih jawabanmu</p>
<div class="options-grid" class:single-col={(currentQuestion.options?.length ?? 0) <= 2}>
{#each currentQuestion.options ?? [] as option, i}
{#each currentQuestion.options ?? [] as option, i (option.id)}
<button
type="button"
class="option-btn"

View File

@ -33,6 +33,15 @@ export class LessonManager {
// Quiz session (single source of truth owned by the manager)
quizSession = $state<QuizSession | null>(null);
quizCurrentIndex = $state(0);
/**
* Mirror eksplisit dari `quizSession.questions[quizCurrentIndex]` sebagai
* `$state` (bukan getter `$derived`). Dipakai komponen agar render selalu
* sinkron saat berpindah soal — terutama saat toggle tipe (mcq ↔ flashcard)
* di mana `{#if type==='mcq'}` berganti blok. Getter bisa stale 1 frame
* (opsi "hilang" lalu "muncul kembali" setelah navigasi). Selalu update
* bareng `quizCurrentIndex` via `_syncCurrentQuestion()`.
*/
quizCurrentQuestion = $state<QuizQuestion | null>(null);
quizResult = $state<QuizResult | null>(null);
// Quiz attempt metadata (anti-cheat / focus-loss)
@ -184,18 +193,19 @@ export class LessonManager {
init(lesson: LessonContent) {
untrack(() => {
this.data = lesson;
this.lessonCompleted = lesson.lesson_completed;
this.isQuizMode = false;
this.quizSession = null;
this.quizCurrentIndex = 0;
this.quizResult = null;
this.quizAttemptId = null;
this.quizStartedAt = null;
this.quizFinalized = false;
this.quizTerminationReason = null;
this.visibilityEventCount = 0;
this.lessonCompleted = lesson.lesson_completed;
this.isQuizMode = false;
this.quizSession = null;
this.quizCurrentIndex = 0;
this.quizCurrentQuestion = null;
this.quizResult = null;
this.quizAttemptId = null;
this.quizStartedAt = null;
this.quizFinalized = false;
this.quizTerminationReason = null;
this.visibilityEventCount = 0;
this.cCode = lesson.initial_code_c || '';
this.cCode = lesson.initial_code_c || '';
this.pythonCode = lesson.initial_python || '';
const hasC = lesson.active_tabs?.includes('c');
@ -249,6 +259,16 @@ export class LessonManager {
return this.quizSession?.questions[this.quizCurrentIndex] ?? null;
}
/**
* Sinkronkan `quizCurrentQuestion` (state eksplisit) dari index saat ini.
* Wajib dipanggil tiap kali `quizCurrentIndex` atau `quizSession` berubah
* agar komponen render (terutama `{#if type==='mcq'}`) langsung dapat
* soal baru, tidak stale 1 frame.
*/
private _syncCurrentQuestion() {
this.quizCurrentQuestion = this.quizSession?.questions[this.quizCurrentIndex] ?? null;
}
get currentQuizAnswer(): QuizAnswer | null {
const q = this.currentQuizQuestion;
return q ? (this.quizSession?.answers[q.id] ?? null) : null;
@ -278,6 +298,7 @@ export class LessonManager {
if (!source?.length) return;
this.quizSession = createQuizSession(source);
this.quizCurrentIndex = 0;
this._syncCurrentQuestion();
this.quizResult = null;
// Satu attempt_id untuk seluruh siklus kuis — semua exit path (finish,
// keluar, navigation, focus_lost, unload) memakai ID yang sama agar
@ -307,6 +328,7 @@ export class LessonManager {
goToQuizQuestion(index: number) {
if (this.quizSession && index >= 0 && index < this.quizSession.questions.length) {
this.quizCurrentIndex = index;
this._syncCurrentQuestion();
}
}