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 { describe, it, expect } from 'vitest';
|
||||||
|
import { createQuizSession, calculateQuizResult } from './quiz-session';
|
||||||
import type { QuizQuestion } from '../types/quiz';
|
import type { QuizQuestion } from '../types/quiz';
|
||||||
import {
|
|
||||||
createQuizSession,
|
|
||||||
answerQuestion,
|
|
||||||
acknowledgeQuestion,
|
|
||||||
acknowledgeFlashcard,
|
|
||||||
calculateQuizResult
|
|
||||||
} from './quiz-session';
|
|
||||||
|
|
||||||
function makeQuestion(
|
function mcq(id: string, nOpts: number, correctIdx = 0): QuizQuestion {
|
||||||
id: string,
|
const options = Array.from({ length: nOpts }, (_, i) => ({
|
||||||
type: 'mcq' | 'flashcard' = 'mcq',
|
id: `${id}-o-${i}`,
|
||||||
correctIdx = 0,
|
text: `opt ${i}`,
|
||||||
category?: 'evaluasi' | 'diagnostik'
|
is_correct: i === correctIdx,
|
||||||
): QuizQuestion {
|
}));
|
||||||
if (type === 'flashcard') {
|
return {
|
||||||
return { id, type: 'flashcard', front: `F-${id}`, back: `B-${id}`, category };
|
id,
|
||||||
}
|
type: 'mcq',
|
||||||
return {
|
question: `Q ${id}`,
|
||||||
id,
|
options,
|
||||||
type: 'mcq',
|
category: 'evaluasi',
|
||||||
category,
|
explanation: '',
|
||||||
question: `Q-${id}`,
|
} as QuizQuestion;
|
||||||
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 }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('createQuizSession', () => {
|
describe('createQuizSession shuffle integrity', () => {
|
||||||
it('does not mutate the source array or its option arrays', () => {
|
it('opsi tetap lengkap & konsisten setelah shuffle (rng acak)', () => {
|
||||||
const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2')];
|
const source = [mcq('q-0', 4), mcq('q-1', 3), mcq('q-2', 2)];
|
||||||
const srcOrder = source.map((q) => q.id);
|
for (let trial = 0; trial < 200; trial++) {
|
||||||
const srcOptOrder = source.map((q) => q.options!.map((o) => o.id));
|
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);
|
it('rng=0 menjaga urutan asli (deterministik)', () => {
|
||||||
source.forEach((q, i) => {
|
const source = [mcq('q-0', 4), mcq('q-1', 3)];
|
||||||
expect(q.options!.map((o) => o.id)).toEqual(srcOptOrder[i]);
|
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']);
|
||||||
|
});
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ import QuizQuestionView from './QuizQuestionView.svelte';
|
||||||
|
|
||||||
// Re-run the render pipeline whenever the active quiz question changes
|
// Re-run the render pipeline whenever the active quiz question changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const q = mgr.currentQuizQuestion;
|
const q = mgr.quizCurrentQuestion;
|
||||||
if (mgr.isQuizMode && q) {
|
if (mgr.isQuizMode && q) {
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (!quizQuestionEl) return;
|
if (!quizQuestionEl) return;
|
||||||
|
|
|
||||||
|
|
@ -51,18 +51,18 @@
|
||||||
<button type="button" class="btn-exit-quiz" onclick={() => mgr.submitQuiz()}>Keluar Kuis</button>
|
<button type="button" class="btn-exit-quiz" onclick={() => mgr.submitQuiz()}>Keluar Kuis</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if mgr.currentQuizQuestion}
|
{#if mgr.quizCurrentQuestion}
|
||||||
<div class="quiz-question-prose">
|
<div class="quiz-question-prose">
|
||||||
{#if mgr.currentQuizQuestion.image}
|
{#if mgr.quizCurrentQuestion.image}
|
||||||
<img
|
<img
|
||||||
src={mgr.currentQuizQuestion.image}
|
src={mgr.quizCurrentQuestion.image}
|
||||||
alt="Ilustrasi soal"
|
alt="Ilustrasi soal"
|
||||||
class="quiz-question-image"
|
class="quiz-question-image"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Hanya prompt soal (question/front). Opsi, jawaban, dan penjelasan
|
<!-- Hanya prompt soal (question/front). Opsi, jawaban, dan penjelasan
|
||||||
TIDAK pernah dirender di sini — itu bagian lembar jawaban/summary. -->
|
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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="quiz-empty">Tidak ada soal.</p>
|
<p class="quiz-empty">Tidak ada soal.</p>
|
||||||
|
|
|
||||||
|
|
@ -300,7 +300,7 @@
|
||||||
{#if currentQuestion?.type === 'mcq'}
|
{#if currentQuestion?.type === 'mcq'}
|
||||||
<p class="answer-sheet-label">Pilih jawabanmu</p>
|
<p class="answer-sheet-label">Pilih jawabanmu</p>
|
||||||
<div class="options-grid" class:single-col={(currentQuestion.options?.length ?? 0) <= 2}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="option-btn"
|
class="option-btn"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ export class LessonManager {
|
||||||
// Quiz session (single source of truth owned by the manager)
|
// Quiz session (single source of truth owned by the manager)
|
||||||
quizSession = $state<QuizSession | null>(null);
|
quizSession = $state<QuizSession | null>(null);
|
||||||
quizCurrentIndex = $state(0);
|
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);
|
quizResult = $state<QuizResult | null>(null);
|
||||||
|
|
||||||
// Quiz attempt metadata (anti-cheat / focus-loss)
|
// Quiz attempt metadata (anti-cheat / focus-loss)
|
||||||
|
|
@ -184,18 +193,19 @@ export class LessonManager {
|
||||||
init(lesson: LessonContent) {
|
init(lesson: LessonContent) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
this.data = lesson;
|
this.data = lesson;
|
||||||
this.lessonCompleted = lesson.lesson_completed;
|
this.lessonCompleted = lesson.lesson_completed;
|
||||||
this.isQuizMode = false;
|
this.isQuizMode = false;
|
||||||
this.quizSession = null;
|
this.quizSession = null;
|
||||||
this.quizCurrentIndex = 0;
|
this.quizCurrentIndex = 0;
|
||||||
this.quizResult = null;
|
this.quizCurrentQuestion = null;
|
||||||
this.quizAttemptId = null;
|
this.quizResult = null;
|
||||||
this.quizStartedAt = null;
|
this.quizAttemptId = null;
|
||||||
this.quizFinalized = false;
|
this.quizStartedAt = null;
|
||||||
this.quizTerminationReason = null;
|
this.quizFinalized = false;
|
||||||
this.visibilityEventCount = 0;
|
this.quizTerminationReason = null;
|
||||||
|
this.visibilityEventCount = 0;
|
||||||
|
|
||||||
this.cCode = lesson.initial_code_c || '';
|
this.cCode = lesson.initial_code_c || '';
|
||||||
this.pythonCode = lesson.initial_python || '';
|
this.pythonCode = lesson.initial_python || '';
|
||||||
|
|
||||||
const hasC = lesson.active_tabs?.includes('c');
|
const hasC = lesson.active_tabs?.includes('c');
|
||||||
|
|
@ -249,6 +259,16 @@ export class LessonManager {
|
||||||
return this.quizSession?.questions[this.quizCurrentIndex] ?? null;
|
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 {
|
get currentQuizAnswer(): QuizAnswer | null {
|
||||||
const q = this.currentQuizQuestion;
|
const q = this.currentQuizQuestion;
|
||||||
return q ? (this.quizSession?.answers[q.id] ?? null) : null;
|
return q ? (this.quizSession?.answers[q.id] ?? null) : null;
|
||||||
|
|
@ -278,6 +298,7 @@ export class LessonManager {
|
||||||
if (!source?.length) return;
|
if (!source?.length) return;
|
||||||
this.quizSession = createQuizSession(source);
|
this.quizSession = createQuizSession(source);
|
||||||
this.quizCurrentIndex = 0;
|
this.quizCurrentIndex = 0;
|
||||||
|
this._syncCurrentQuestion();
|
||||||
this.quizResult = null;
|
this.quizResult = null;
|
||||||
// Satu attempt_id untuk seluruh siklus kuis — semua exit path (finish,
|
// Satu attempt_id untuk seluruh siklus kuis — semua exit path (finish,
|
||||||
// keluar, navigation, focus_lost, unload) memakai ID yang sama agar
|
// keluar, navigation, focus_lost, unload) memakai ID yang sama agar
|
||||||
|
|
@ -307,6 +328,7 @@ export class LessonManager {
|
||||||
goToQuizQuestion(index: number) {
|
goToQuizQuestion(index: number) {
|
||||||
if (this.quizSession && index >= 0 && index < this.quizSession.questions.length) {
|
if (this.quizSession && index >= 0 && index < this.quizSession.questions.length) {
|
||||||
this.quizCurrentIndex = index;
|
this.quizCurrentIndex = index;
|
||||||
|
this._syncCurrentQuestion();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue