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:
parent
5cc4ca472b
commit
da6d7e25d3
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue