feat(quiz): tag kategori diagnostik + breakdown evaluasi/diagnostik di kuis & report guru
- Backend parser (_parse_flashcards): deteksi marker ::diagnostic di baris sendiri -> category='diagnostik' (default evaluasi), strip dari tampilan.
- Persistensi: kolom answers_json (migration 0003) simpan ringkasan per-soal {question_id, selected_option_id, is_correct, category}.
- quiz_attempts: validasi/serialize answers_json + endpoint GET /quiz-attempts/<lesson> untuk review-after-refresh.
- quiz-session: hitung evalCorrect/evalTotal/diagCorrect/diagTotal/diagUnmastered; skor resmi = evaluasi saja.
- Frontend: QuizTab badge per-soal + breakdown 'Belum dikuasai'; progress guru tampil badge Eval/Diag per lesson.
- Content: pindahkan ::diagnostic ke baris sendiri (fix kerusakan fenced code/blockquote dari edit sebelumnya).
- Tests: unit parser kategori + payload answers.
This commit is contained in:
parent
79edcd1596
commit
58cb35a01a
|
|
@ -10,6 +10,7 @@ image: https://upload.wikimedia.org/wikipedia/commons/1/18/C_Programming_Languag
|
|||
> Penjelasan: Bahasa C sebenarnya dikategorikan sebagai bahasa tingkat menengah (*middle-level language*) karena memiliki fitur bahasa tingkat rendah (seperti manipulasi memori) sekaligus fitur bahasa tingkat tinggi (seperti struktur kontrol yang manusiawi).
|
||||
|
||||
### Apa output dari `printf("%d", 5 + 5);`?
|
||||
::diagnostic
|
||||
- [] 55
|
||||
- [x] 10
|
||||
- [] 5 + 5
|
||||
|
|
@ -29,7 +30,8 @@ Berapakah keluaran program di atas?
|
|||
> Pembahasan: `x * 3` menghasilkan `6` karena nilai `x` adalah `2`.
|
||||
|
||||
### Berdasarkan tabel berikut, tipe data mana yang tepat untuk menyimpan `3.14`?
|
||||
| Tipe | Ukuran | Rentang |
|
||||
::diagnostic
|
||||
|| Tipe | Ukuran | Rentang ||
|
||||
|--------|--------|--------------------|
|
||||
| int | 4 byte | -2^31 s/d 2^31-1 |
|
||||
| float | 4 byte | ~1e-38 s/d ~1e38 |
|
||||
|
|
@ -40,6 +42,7 @@ Berapakah keluaran program di atas?
|
|||
> Pembahasan: `double` punya presisi lebih tinggi dan rentang lebih luas untuk bilangan pecahan.
|
||||
|
||||
### Berapakah nilai dari $\int_0^1 2x \, dx$?
|
||||
::diagnostic
|
||||
- [x] 1
|
||||
- [] 2
|
||||
- [] 0.5
|
||||
|
|
@ -67,4 +70,4 @@ image: demo_quiz.png
|
|||
- [] Kuning
|
||||
> Penjelasan: Langit terlihat biru karena hamburan Rayleigh — cahaya matahari dihamburkan oleh atmosfer bumi.
|
||||
|
||||
---END_QUIZ_FLASHCARD---
|
||||
---END_QUIZ_FLASHCARD---
|
||||
|
|
@ -58,6 +58,7 @@ describe('submitQuizAttempt', () => {
|
|||
occurred_at: '2026-08-09T14:04:44.000Z',
|
||||
started_at: '2026-08-09T14:03:00.000Z',
|
||||
visibility_event_count: 1,
|
||||
answers: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
|
@ -85,7 +86,8 @@ describe('submitQuizAttempt', () => {
|
|||
score: '2/4',
|
||||
occurred_at: p.occurred_at,
|
||||
started_at: p.started_at,
|
||||
visibility_event_count: 1
|
||||
visibility_event_count: 1,
|
||||
answers: []
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -110,37 +112,39 @@ describe('quizAttemptBeacon', () => {
|
|||
vi.stubGlobal('navigator', { sendBeacon });
|
||||
|
||||
const p: QuizAttemptSubmission = {
|
||||
attempt_id: '3f2f8c24-8c1a-4b2a-9e5a-1a2b3c4d5e6f',
|
||||
token: 'student-token',
|
||||
lesson_name: 'quiz_test',
|
||||
status: 'terminated',
|
||||
termination_reason: 'focus_lost',
|
||||
score: '2/4',
|
||||
occurred_at: '2026-08-09T14:04:44.000Z',
|
||||
started_at: '2026-08-09T14:03:00.000Z',
|
||||
visibility_event_count: 1
|
||||
};
|
||||
attempt_id: '3f2f8c24-8c1a-4b2a-9e5a-1a2b3c4d5e6f',
|
||||
token: 'student-token',
|
||||
lesson_name: 'quiz_test',
|
||||
status: 'terminated',
|
||||
termination_reason: 'focus_lost',
|
||||
score: '2/4',
|
||||
occurred_at: '2026-08-09T14:04:44.000Z',
|
||||
started_at: '2026-08-09T14:03:00.000Z',
|
||||
visibility_event_count: 1,
|
||||
answers: [],
|
||||
};
|
||||
|
||||
const ok = quizAttemptBeacon(p);
|
||||
expect(ok).toBe(true);
|
||||
expect(sendBeacon).toHaveBeenCalledTimes(1);
|
||||
expect(sendBeacon.mock.calls[0][0]).toBe('/api/quiz-attempts/submit');
|
||||
const blob = sendBeacon.mock.calls[0][1];
|
||||
expect(blob.type).toBe('application/json');
|
||||
const text = await blob.text();
|
||||
const body = JSON.parse(text);
|
||||
expect(body).toEqual({
|
||||
attempt_id: p.attempt_id,
|
||||
token: p.token,
|
||||
lesson_name: 'quiz_test',
|
||||
status: 'terminated',
|
||||
termination_reason: 'focus_lost',
|
||||
score: '2/4',
|
||||
occurred_at: p.occurred_at,
|
||||
started_at: p.started_at,
|
||||
visibility_event_count: 1
|
||||
const ok = quizAttemptBeacon(p);
|
||||
expect(ok).toBe(true);
|
||||
expect(sendBeacon).toHaveBeenCalledTimes(1);
|
||||
expect(sendBeacon.mock.calls[0][0]).toBe('/api/quiz-attempts/submit');
|
||||
const blob = sendBeacon.mock.calls[0][1];
|
||||
expect(blob.type).toBe('application/json');
|
||||
const text = await blob.text();
|
||||
const body = JSON.parse(text);
|
||||
expect(body).toEqual({
|
||||
attempt_id: p.attempt_id,
|
||||
token: p.token,
|
||||
lesson_name: 'quiz_test',
|
||||
status: 'terminated',
|
||||
termination_reason: 'focus_lost',
|
||||
score: '2/4',
|
||||
occurred_at: p.occurred_at,
|
||||
started_at: p.started_at,
|
||||
visibility_event_count: 1,
|
||||
answers: p.answers
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('tidak melempar dan mengembalikan false saat sendBeacon tidak tersedia', () => {
|
||||
vi.stubGlobal('navigator', {});
|
||||
|
|
@ -153,7 +157,8 @@ describe('quizAttemptBeacon', () => {
|
|||
score: '2/4',
|
||||
occurred_at: '2026-08-09T14:04:44.000Z',
|
||||
started_at: '2026-08-09T14:03:00.000Z',
|
||||
visibility_event_count: 1
|
||||
visibility_event_count: 1,
|
||||
answers: []
|
||||
};
|
||||
expect(quizAttemptBeacon(p)).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -155,6 +155,15 @@ export interface QuizAttemptSubmission {
|
|||
occurred_at: string;
|
||||
started_at: string;
|
||||
visibility_event_count: number;
|
||||
/** Ringkasan per-soal untuk review-after-refresh + breakdown kategori. */
|
||||
answers: QuizAnswerPayload[];
|
||||
}
|
||||
|
||||
export interface QuizAnswerPayload {
|
||||
question_id: string;
|
||||
selected_option_id: string | null;
|
||||
is_correct: boolean;
|
||||
category: 'evaluasi' | 'diagnostik';
|
||||
}
|
||||
|
||||
export interface QuizAttemptSubmitResponse {
|
||||
|
|
@ -164,6 +173,20 @@ export interface QuizAttemptSubmitResponse {
|
|||
message: string;
|
||||
}
|
||||
|
||||
/** Attempt yang dikembalikan endpoint GET (untuk review-after-refresh). */
|
||||
export interface QuizAttemptFetchResponse {
|
||||
success: boolean;
|
||||
attempt_id: string;
|
||||
status: 'submitted' | 'terminated';
|
||||
termination_reason: QuizTerminationReason | null;
|
||||
score: string;
|
||||
score_earned?: number | null;
|
||||
score_total?: number | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
answers: QuizAnswerPayload[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit finalisasi kuis (satu attempt) — endpoint atomic di backend.
|
||||
* Dipakai untuk exit path normal: tombol Keluar, SPA navigation, finish.
|
||||
|
|
@ -175,6 +198,20 @@ export function submitQuizAttempt(
|
|||
return post<QuizAttemptSubmitResponse>('/quiz-attempts/submit', payload, customFetch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch attempt kuis siswa untuk sebuah lesson (GET /quiz-attempts/<lesson>).
|
||||
* Dipakai saat halaman dibuka kembali supaya review per-soal tetap tampil
|
||||
* setelah refresh (one-attempt policy → session hilang, tapi attempt persist).
|
||||
*/
|
||||
export function fetchQuizAttempt(
|
||||
token: string,
|
||||
lessonName: string,
|
||||
customFetch = fetch
|
||||
): Promise<QuizAttemptFetchResponse> {
|
||||
const query = `token=${encodeURIComponent(token)}`;
|
||||
return get<QuizAttemptFetchResponse>(`/quiz-attempts/${lessonName}?${query}`, customFetch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirim attempt via `navigator.sendBeacon` (synchronous, fire-and-forget).
|
||||
* Dipakai untuk event lifecycle yang bisa membuat browser suspend
|
||||
|
|
|
|||
|
|
@ -13,8 +13,16 @@ export interface QuizResult {
|
|||
unansweredMcq: number;
|
||||
completedFlashcards: number;
|
||||
allHandled: boolean;
|
||||
/** e.g. "2/4" when MCQ exist, "completed" when only flashcards. */
|
||||
/** Skor resmi = evaluasi saja. "X/Y" bila ada MCQ evaluasi, "completed" bila tidak ada MCQ evaluasi (hanya diagnostik/flashcard). */
|
||||
statusString: string;
|
||||
/** MCQ dengan category 'evaluasi' */
|
||||
evalCorrect: number;
|
||||
evalTotal: number;
|
||||
/** MCQ dengan category 'diagnostik' */
|
||||
diagCorrect: number;
|
||||
diagTotal: number;
|
||||
/** Soal diagnostik yang salah/atur tidak dijawab */
|
||||
diagUnmastered: QuizQuestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,12 +82,28 @@ export function calculateQuizResult(session: QuizSession): QuizResult {
|
|||
let unansweredMcq = 0;
|
||||
let completedFlashcards = 0;
|
||||
|
||||
let evalCorrect = 0;
|
||||
let evalTotal = 0;
|
||||
let diagCorrect = 0;
|
||||
let diagTotal = 0;
|
||||
const diagUnmastered: QuizQuestion[] = [];
|
||||
|
||||
for (const q of session.questions) {
|
||||
const a = session.answers[q.id];
|
||||
const isEval = q.category !== 'diagnostik'; // default 'evaluasi'
|
||||
if (q.type === 'mcq') {
|
||||
totalMcq += 1;
|
||||
if (a.isCorrect) correctMcq += 1;
|
||||
if (a.selectedOptionId === null) unansweredMcq += 1;
|
||||
|
||||
if (isEval) {
|
||||
evalTotal += 1;
|
||||
if (a.isCorrect) evalCorrect += 1;
|
||||
} else {
|
||||
diagTotal += 1;
|
||||
if (a.isCorrect) diagCorrect += 1;
|
||||
else diagUnmastered.push(q);
|
||||
}
|
||||
} else if (a.acknowledged) {
|
||||
completedFlashcards += 1;
|
||||
}
|
||||
|
|
@ -90,7 +114,20 @@ export function calculateQuizResult(session: QuizSession): QuizResult {
|
|||
? session.answers[q.id].selectedOptionId !== null
|
||||
: session.answers[q.id].acknowledged
|
||||
);
|
||||
const statusString = totalMcq > 0 ? `${correctMcq}/${totalMcq}` : 'completed';
|
||||
// Skor resmi = evaluasi saja; bila tidak ada MCQ evaluasi → 'completed'.
|
||||
const statusString = evalTotal > 0 ? `${evalCorrect}/${evalTotal}` : 'completed';
|
||||
|
||||
return { totalMcq, correctMcq, unansweredMcq, completedFlashcards, allHandled, statusString };
|
||||
return {
|
||||
totalMcq,
|
||||
correctMcq,
|
||||
unansweredMcq,
|
||||
completedFlashcards,
|
||||
allHandled,
|
||||
statusString,
|
||||
evalCorrect,
|
||||
evalTotal,
|
||||
diagCorrect,
|
||||
diagTotal,
|
||||
diagUnmastered,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface QuizOption {
|
|||
export interface QuizQuestion {
|
||||
id: string;
|
||||
type: 'flashcard' | 'mcq';
|
||||
category?: 'evaluasi' | 'diagnostik';
|
||||
question?: string;
|
||||
front?: string;
|
||||
back?: string;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { authLoggedIn } from '$stores/auth';
|
||||
import { authLoggedIn, authToken } from '$stores/auth';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount } from 'svelte';
|
||||
import { renderMath } from '$lib/actions/renderMath';
|
||||
import { shouldShowQuizReview } from '$services/quiz-integrity';
|
||||
import type { LessonManager } from './lesson.svelte';
|
||||
import type { QuizQuestion, QuizAnswer } from '$types/quiz';
|
||||
import { fetchQuizAttempt } from '$services/api';
|
||||
import type { QuizAttemptFetchResponse } from '$services/api';
|
||||
|
||||
interface Props {
|
||||
mgr: LessonManager;
|
||||
|
|
@ -26,6 +31,50 @@
|
|||
// pembahasan (siswa yang kehilangan fokus tidak mendapat bocoran jawaban).
|
||||
const showReview = $derived(shouldShowQuizReview(mgr.quizTerminationReason));
|
||||
|
||||
// --- Review-after-refresh: fetch stored attempt (one-attempt → session lost) ---
|
||||
let storedAttempt: QuizAttemptFetchResponse | null = $state(null);
|
||||
let reviewQuestions: QuizQuestion[] = $state([]);
|
||||
let reviewAnswers: Record<string, QuizAnswer> = $state({});
|
||||
|
||||
async function loadStoredAttempt() {
|
||||
const token = get(authToken) || localStorage.getItem('student_token') || '';
|
||||
if (!token || !mgr.data) return;
|
||||
const lessonName = mgr.slug.replace('.md', '');
|
||||
try {
|
||||
const resp = await fetchQuizAttempt(token, lessonName);
|
||||
if (resp.success && resp.answers?.length) {
|
||||
storedAttempt = resp;
|
||||
// Rekonstruksi question+answer maps dari stored attempt agar
|
||||
// summary review per-soal bisa dirender setelah refresh.
|
||||
const src = mgr.data?.quiz_data ?? [];
|
||||
// Cocokkan by question_id; gunakan urutan asli (parser order).
|
||||
reviewQuestions = src.filter((q) => resp.answers.some((a) => a.question_id === q.id));
|
||||
reviewAnswers = {};
|
||||
for (const ans of resp.answers) {
|
||||
const q = src.find((qq) => qq.id === ans.question_id);
|
||||
if (q) {
|
||||
reviewAnswers[q.id] = {
|
||||
questionId: q.id,
|
||||
selectedOptionId: ans.selected_option_id,
|
||||
acknowledged: false,
|
||||
isCorrect: ans.is_correct,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort; review akan tetap tersedia di session bila masih hidup
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch attempt bila sudah selesai tapi session hilang (refresh/page-open)
|
||||
onMount(() => {
|
||||
const r = result;
|
||||
if (!r && quizAlreadyCompleted && !mgr.quizSession) {
|
||||
loadStoredAttempt();
|
||||
}
|
||||
});
|
||||
|
||||
function handleExit() {
|
||||
if (!mgr.isQuizMode) return;
|
||||
|
||||
|
|
@ -57,7 +106,15 @@
|
|||
<div class="summary-icon">🏁</div>
|
||||
<h2>Kuis Selesai!</h2>
|
||||
{#if quizAlreadyCompleted && !result}
|
||||
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
|
||||
{#if storedAttempt}
|
||||
{@const s = storedAttempt}
|
||||
<p class="score-display">Nilai Anda: <strong>{s.score}</strong></p>
|
||||
<div class="score-breakdown">
|
||||
<span class="breakdown-item">Evaluasi: <strong>{s.score_earned ?? '-'}/{s.score_total ?? '-'}</strong></span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
|
||||
{/if}
|
||||
<div class="alert alert-info">
|
||||
Kuis ini sudah diselesaikan. Silakan hubungi guru jika ingin mengulang.
|
||||
</div>
|
||||
|
|
@ -65,7 +122,7 @@
|
|||
{@const totalMcq = result.totalMcq}
|
||||
{#if mgr.quizTerminationReason === 'focus_lost'}
|
||||
<div class="alert alert-danger">
|
||||
Kuis dihentikan karena halaman kehilangan fokus. Skor disimpan dan percobaan tidak dapat diulang.
|
||||
Kuis dihentikan karena halaman kehilangan fokus. Skor disimpan dan percobaann tidak dapat diulang.
|
||||
</div>
|
||||
{/if}
|
||||
{@const correct = result.correctMcq}
|
||||
|
|
@ -81,14 +138,33 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if result.evalTotal > 0 || result.diagTotal > 0}
|
||||
<div class="score-breakdown">
|
||||
{#if result.evalTotal > 0}
|
||||
<span class="breakdown-item">Evaluasi: <strong>{result.evalCorrect}/{result.evalTotal}</strong></span>
|
||||
{/if}
|
||||
{#if result.diagTotal > 0}
|
||||
<span class="breakdown-item">Diagnostik: <strong>{result.diagCorrect}/{result.diagTotal}</strong></span>
|
||||
{#if result.diagUnmastered.length > 0}
|
||||
<span class="breakdown-item unmastered">
|
||||
Belum dikuasai:
|
||||
{#each result.diagUnmastered as q}
|
||||
<span class="unmastered-topic">{(q.front ?? q.question ?? 'Soal').replace(/<[^>]*>/g, '').slice(0, 40)}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showReview && questions.length > 0}
|
||||
<div class="summary-review">
|
||||
<h3 class="review-title">Pembahasan</h3>
|
||||
{#each questions as q, i}
|
||||
{@const a = mgr.quizSession!.answers[q.id]}
|
||||
<div class="review-item">
|
||||
<div class="review-item" data-category={q.category ?? 'evaluasi'}>
|
||||
<div class="review-header">
|
||||
<span class="review-number">Soal {i + 1}</span>
|
||||
<span class="review-number">Soal {i + 1} <span class="review-category-badge">{q.category === 'diagnostik' ? 'Diagnostik' : 'Evaluasi'}</span></span>
|
||||
{#if q.type === 'mcq'}
|
||||
<span class="review-badge" class:ok={a.isCorrect} class:bad={!a.isCorrect}>
|
||||
{a.isCorrect ? '✓ Benar' : '✗ Salah'}
|
||||
|
|
@ -134,6 +210,52 @@
|
|||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if storedAttempt && !result && showReview && reviewQuestions.length > 0}
|
||||
<div class="summary-review">
|
||||
<h3 class="review-title">Pembahasan (tersimpan)</h3>
|
||||
{#each reviewQuestions as q, i}
|
||||
{@const a = reviewAnswers[q.id]}
|
||||
<div class="review-item" data-category={q.category ?? 'evaluasi'}>
|
||||
<div class="review-header">
|
||||
<span class="review-number">Soal {i + 1} <span class="review-category-badge">{q.category === 'diagnostik' ? 'Diagnostik' : 'Evaluasi'}</span></span>
|
||||
{#if q.type === 'mcq'}
|
||||
<span class="review-badge" class:ok={a.isCorrect} class:bad={!a.isCorrect}>
|
||||
{a.isCorrect ? '✓ Benar' : '✗ Salah'}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="review-badge ok">Selesai</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="review-question">{@html q.question ?? q.front ?? ''}</div>
|
||||
{#if q.type === 'mcq' && q.options}
|
||||
<div class="review-options">
|
||||
{#each q.options as opt}
|
||||
<div
|
||||
class="review-option"
|
||||
class:chosen={opt.id === a.selectedOptionId}
|
||||
class:correct-opt={opt.is_correct}
|
||||
>
|
||||
<span class="review-option-mark">
|
||||
{opt.is_correct ? '✅' : opt.id === a.selectedOptionId ? '❌' : '○'}
|
||||
</span>
|
||||
<span class="review-option-text">{@html opt.text}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !a.isCorrect && a.selectedOptionId === null}
|
||||
<p class="review-unanswered">Tidak dijawab — dianggap salah.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="review-back">{@html q.back ?? ''}</div>
|
||||
{/if}
|
||||
{#if q.explanation}
|
||||
<div class="explanation-box">{@html q.explanation}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if !mgr.isQuizMode}
|
||||
|
|
@ -354,6 +476,12 @@
|
|||
.explanation-box { font-size: 0.85rem; line-height: 1.5; padding: 1rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; text-align: left; margin-top: 0.5rem; }
|
||||
.explanation-box :global(p) { margin: 0; }
|
||||
|
||||
.score-breakdown { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin: 0.75rem 0 1.25rem; }
|
||||
.breakdown-item { font-size: 0.95rem; padding: 0.35rem 0.7rem; border-radius: 999px; background: var(--color-bg-secondary); border: 1px solid var(--color-border); }
|
||||
.breakdown-item strong { color: var(--color-primary); }
|
||||
.breakdown-item.unmastered { flex-basis: 100%; justify-content: center; }
|
||||
.unmastered-topic { font-size: 0.8rem; background: rgba(220, 53, 69, 0.1); color: var(--color-danger); padding: 0.15rem 0.5rem; border-radius: 6px; margin: 0.15rem; display: inline-block; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.option-btn { padding: 0.75rem; font-size: 0.95rem; }
|
||||
.quiz-container { padding: 1rem; }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { pickDefaultTab } from '$services/lesson-tabs';
|
|||
import { tick, untrack } from 'svelte';
|
||||
import { auth, authLoggedIn, authToken } from '$stores/auth';
|
||||
import { lessonContext } from '$stores/lessonContext';
|
||||
import { compileCode, trackProgress, submitQuizAttempt, quizAttemptBeacon, type QuizAttemptSubmission } from '$services/api';
|
||||
import { compileCode, trackProgress, submitQuizAttempt, quizAttemptBeacon, fetchQuizAttempt, type QuizAttemptSubmission, type QuizAttemptFetchResponse } from '$services/api';
|
||||
import { createAttemptId, type QuizTerminationReason } from '$services/quiz-integrity';
|
||||
import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
|
||||
import { evaluateFlowchartSubmission } from '$services/flowchart-evaluator';
|
||||
|
|
@ -416,7 +416,16 @@ export class LessonManager {
|
|||
score: result.statusString,
|
||||
occurred_at: now,
|
||||
started_at: this.quizStartedAt ?? now,
|
||||
visibility_event_count: this.visibilityEventCount
|
||||
visibility_event_count: this.visibilityEventCount,
|
||||
answers: this.quizSession.questions.map((q) => {
|
||||
const a = this.quizSession!.answers[q.id];
|
||||
return {
|
||||
question_id: q.id,
|
||||
selected_option_id: a.selectedOptionId,
|
||||
is_correct: a.isCorrect,
|
||||
category: (q.category === 'diagnostik' ? 'diagnostik' : 'evaluasi') as 'evaluasi' | 'diagnostik'
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@
|
|||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
// `_diag_unmastered` dikirim backend sebagai JSON string array of question_id.
|
||||
function parseUnmastered(raw: string | number | boolean | undefined): string[] {
|
||||
if (typeof raw !== 'string') return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
let students = $state<StudentProgress[]>([]);
|
||||
let lessons = $state<LessonHeader[]>([]);
|
||||
let loading = $state(true);
|
||||
|
|
@ -247,6 +258,9 @@
|
|||
{@const violation = student[key + '_has_violation']}
|
||||
{@const violationReason = student[key + '_termination_reason']}
|
||||
{@const violationTime = student[key + '_attempt_finished_at']}
|
||||
{@const evalScore = student[key + '_eval']}
|
||||
{@const diagScore = student[key + '_diag']}
|
||||
{@const diagUnmastered = parseUnmastered(student[key + '_diag_unmastered'])}
|
||||
<td class="status-cell">
|
||||
<div class="cell-content">
|
||||
{#if status === 'completed'}
|
||||
|
|
@ -266,6 +280,21 @@
|
|||
</span>
|
||||
{/if}
|
||||
|
||||
{#if evalScore}
|
||||
<span class="badge eval" title="Skor evaluasi (skor resmi)">Eval: {evalScore}</span>
|
||||
{/if}
|
||||
{#if diagScore}
|
||||
<span class="badge diag" title="Skor soal diagnostik">Diag: {diagScore}</span>
|
||||
{#if diagUnmastered.length > 0}
|
||||
<span
|
||||
class="badge unmastered"
|
||||
title="Belum dikuasai: {diagUnmastered.join(', ')}"
|
||||
>
|
||||
{diagUnmastered.length} belum dikuasai
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if status && status !== 'not_started'}
|
||||
<button
|
||||
class="btn-reset-mini"
|
||||
|
|
@ -443,10 +472,33 @@
|
|||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.eval, .badge.diag, .badge.unmastered {
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.eval {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg));
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 40%, var(--color-bg));
|
||||
}
|
||||
.badge.diag {
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.badge.unmastered {
|
||||
color: var(--color-danger, #e8590c);
|
||||
background: color-mix(in srgb, var(--color-danger, #e8590c) 10%, var(--color-bg));
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger, #e8590c) 40%, var(--color-bg));
|
||||
}
|
||||
.cell-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn-reset-mini {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
"""quiz_attempts: simpan ringkasan per-soal (review-after-refresh + breakdown kategori)
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-08-14
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("quiz_attempts", sa.Column("answers_json", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("quiz_attempts", "answers_json")
|
||||
|
|
@ -15,6 +15,7 @@ Kontrak:
|
|||
- Raw token tidak pernah di-log; response hanya memuat status/message/attempt_id.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
|
@ -119,6 +120,17 @@ def submit_quiz_attempt():
|
|||
):
|
||||
return jsonify({"success": False, "message": "visibility_event_count tidak valid"}), 400
|
||||
|
||||
# answers: ringkasan per-soal [{question_id, selected_option_id, is_correct, category}]
|
||||
answers_raw = data.get("answers")
|
||||
answers_json = None
|
||||
if answers_raw is not None:
|
||||
if not isinstance(answers_raw, list):
|
||||
return jsonify({"success": False, "message": "answers harus berupa list"}), 400
|
||||
try:
|
||||
answers_json = json.dumps(answers_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"success": False, "message": "answers tidak dapat diserialisasi"}), 400
|
||||
|
||||
if SessionLocal is None:
|
||||
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
|
||||
|
||||
|
|
@ -145,6 +157,7 @@ def submit_quiz_attempt():
|
|||
finished_at=datetime.now(timezone.utc),
|
||||
visibility_event_count=visibility_count,
|
||||
user_agent=user_agent,
|
||||
answers_json=answers_json,
|
||||
)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
|
|
@ -185,3 +198,57 @@ def submit_quiz_attempt():
|
|||
"attempt_id": attempt.id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@quiz_attempts_bp.route("/quiz-attempts/<lesson_name>", methods=["GET"])
|
||||
def get_quiz_attempt(lesson_name: str):
|
||||
"""Dapatkan attempt kuis siswa untuk lesson (untuk review-after-refresh).
|
||||
|
||||
Auth: student_token via query param `token` (siswa hanya lihat attempt miliknya).
|
||||
Response: attempt_id, status, termination_reason, score, score_earned, score_total,
|
||||
started_at, finished_at, answers (parsed dari answers_json).
|
||||
"""
|
||||
token = (request.args.get("token") or "").strip()
|
||||
if not token:
|
||||
return jsonify({"success": False, "message": "Token wajib"}), 400
|
||||
if SessionLocal is None:
|
||||
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = repo.get_user_by_token(db, token)
|
||||
if user is None:
|
||||
return jsonify({"success": False, "message": "Token tidak valid"}), 401
|
||||
lesson = repo.get_lesson_by_slug(db, lesson_name)
|
||||
if lesson is None:
|
||||
return jsonify({"success": False, "message": "Lesson tidak dikenal"}), 404
|
||||
attempt = repo.get_quiz_attempt_for_user_lesson(
|
||||
db, user_id=user.id, lesson_id=lesson.id
|
||||
)
|
||||
if attempt is None:
|
||||
return jsonify({"success": False, "message": "Belum ada attempt"}), 404
|
||||
|
||||
try:
|
||||
answers = json.loads(attempt.answers_json) if attempt.answers_json else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
answers = []
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"attempt_id": attempt.id,
|
||||
"status": attempt.status,
|
||||
"termination_reason": attempt.termination_reason,
|
||||
"score": f"{attempt.score_earned}/{attempt.score_total}"
|
||||
if attempt.score_earned is not None and attempt.score_total is not None
|
||||
else "completed",
|
||||
"score_earned": attempt.score_earned,
|
||||
"score_total": attempt.score_total,
|
||||
"started_at": attempt.started_at.isoformat() if attempt.started_at else None,
|
||||
"finished_at": attempt.finished_at.isoformat() if attempt.finished_at else None,
|
||||
"answers": answers,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("get_quiz_attempt gagal")
|
||||
return jsonify({"success": False, "message": "Gagal mengambil attempt"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -796,6 +796,12 @@ def _parse_flashcards(text):
|
|||
image_url = f'/assets/{image_url}'
|
||||
body = re.sub(r'^\s*image:\s*.*$', '', body, flags=re.MULTILINE).strip()
|
||||
|
||||
# Check for ::diagnostic marker (stripped from display, sets category)
|
||||
category = 'evaluasi'
|
||||
if re.search(r'^\s*::diagnostic\s*$', body, re.MULTILINE):
|
||||
category = 'diagnostik'
|
||||
body = re.sub(r'^\s*::diagnostic\s*$', '', body, flags=re.MULTILINE).strip()
|
||||
|
||||
# Option lines outside fenced code, in original order
|
||||
option_lines = list(_iter_option_lines(body))
|
||||
first_option_idx = option_lines[0][0] if option_lines else len(body)
|
||||
|
|
@ -849,7 +855,8 @@ def _parse_flashcards(text):
|
|||
'question': md.markdown(prompt_text, extensions=MD_EXTENSIONS),
|
||||
'options': parsed_options,
|
||||
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
|
||||
'image': image_url
|
||||
'image': image_url,
|
||||
'category': category
|
||||
})
|
||||
else:
|
||||
# It's a simple Flashcard
|
||||
|
|
@ -865,7 +872,8 @@ def _parse_flashcards(text):
|
|||
'back': md.markdown(clean_back, extensions=MD_EXTENSIONS),
|
||||
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
|
||||
'image': image_url,
|
||||
'options': None
|
||||
'options': None,
|
||||
'category': category
|
||||
})
|
||||
|
||||
return flashcards
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ from sqlalchemy import (
|
|||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
|
|
@ -142,6 +144,11 @@ class QuizAttempt(Base):
|
|||
# Diagnosis device/browser saja — bukan dasar hukuman tambahan.
|
||||
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Ringkasan per-soal (question_id -> selected_option_id, is_correct, category).
|
||||
# Digunakan frontend untuk render review-after-refresh + breakdown kategori;
|
||||
# tidak memengaruui skor resmi (evaluasi saja, lihat quiz-session.ts).
|
||||
answers_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="quiz_attempts")
|
||||
lesson: Mapped["Lesson"] = relationship(back_populates="quiz_attempts")
|
||||
|
||||
|
|
|
|||
|
|
@ -253,6 +253,19 @@ def list_quiz_attempts_for_user(db: Session, *, user_id: str) -> list[QuizAttemp
|
|||
)
|
||||
|
||||
|
||||
def get_quiz_attempt_for_user_lesson(db: Session, *, user_id: str, lesson_id: str) -> QuizAttempt | None:
|
||||
"""Satu attempt kuis per (user, lesson) — one-attempt policy (unique constraint)."""
|
||||
return db.scalars(
|
||||
select(QuizAttempt)
|
||||
.where(
|
||||
QuizAttempt.user_id == user_id,
|
||||
QuizAttempt.lesson_id == lesson_id,
|
||||
)
|
||||
.order_by(QuizAttempt.finished_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
|
||||
def delete_quiz_attempts(db: Session, *, user_id: str, lesson_id: str) -> int:
|
||||
"""Hapus attempt (one-attempt) — dipakai saat guru me-reset progress."""
|
||||
attempts = list(
|
||||
|
|
@ -282,6 +295,7 @@ def finalize_quiz_attempt(
|
|||
finished_at: datetime,
|
||||
visibility_event_count: int,
|
||||
user_agent: str | None = None,
|
||||
answers_json: str | None = None,
|
||||
) -> tuple[QuizAttempt, bool]:
|
||||
"""Insert/confirm SATU attempt kuis — idempotent per `attempt_id`.
|
||||
|
||||
|
|
@ -315,6 +329,7 @@ def finalize_quiz_attempt(
|
|||
finished_at=finished_at,
|
||||
visibility_event_count=visibility_event_count,
|
||||
user_agent=user_agent,
|
||||
answers_json=answers_json,
|
||||
)
|
||||
db.add(attempt)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ validasi via HMAC-SHA256 digest + role eksplisit di tabel users.
|
|||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
|
|
@ -216,6 +217,41 @@ def get_all_students_progress(all_lessons_func):
|
|||
student[f"{lesson.slug}_attempt_finished_at"] = (
|
||||
attempt.finished_at.isoformat() if attempt and attempt.finished_at else ""
|
||||
)
|
||||
# Breakdown kategori (evaluasi / diagnostik) dari answers_json attempt —
|
||||
# untuk report guru. `eval_correct/eval_total` = skor resmi;
|
||||
# `diag_correct/diag_total` = info diagnostik.
|
||||
if attempt and attempt.answers_json:
|
||||
try:
|
||||
answers = json.loads(attempt.answers_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
answers = []
|
||||
eval_correct = 0
|
||||
eval_total = 0
|
||||
diag_correct = 0
|
||||
diag_total = 0
|
||||
for ans in answers:
|
||||
cat = ans.get('category', 'evaluasi')
|
||||
if cat == 'diagnostik':
|
||||
diag_total += 1
|
||||
if ans.get('is_correct'):
|
||||
diag_correct += 1
|
||||
else:
|
||||
eval_total += 1
|
||||
if ans.get('is_correct'):
|
||||
eval_correct += 1
|
||||
student[f"{lesson.slug}_eval"] = f"{eval_correct}/{eval_total}"
|
||||
student[f"{lesson.slug}_diag"] = f"{diag_correct}/{diag_total}"
|
||||
student[f"{lesson.slug}_diag_unmastered"] = json.dumps(
|
||||
[
|
||||
a.get('question_id', '')
|
||||
for a in answers
|
||||
if a.get('category') == 'diagnostik' and not a.get('is_correct')
|
||||
]
|
||||
)
|
||||
else:
|
||||
student[f"{lesson.slug}_eval"] = ""
|
||||
student[f"{lesson.slug}_diag"] = ""
|
||||
student[f"{lesson.slug}_diag_unmastered"] = "[]"
|
||||
student['completed_count'] = count_completed_lessons(db, user_id=user.id)
|
||||
students.append(student)
|
||||
return students, ordered_lessons
|
||||
|
|
|
|||
|
|
@ -198,3 +198,77 @@ def test_old_flat_format_still_parses_with_ids():
|
|||
assert quiz[0]['type'] == 'mcq'
|
||||
assert len(quiz[0]['options']) == 2
|
||||
assert quiz[0]['options'][1]['is_correct'] is True
|
||||
|
||||
|
||||
def test_default_category_is_evaluasi():
|
||||
md_text = """### Mana yang benar?
|
||||
- [] Salah
|
||||
- [x] Benar
|
||||
"""
|
||||
quiz = _parse_flashcards(md_text)
|
||||
assert len(quiz) == 1
|
||||
assert quiz[0]['category'] == 'evaluasi'
|
||||
|
||||
|
||||
def test_diagnostic_marker_on_own_line_sets_category_mcq():
|
||||
md_text = """### Apa output dari `printf("%d", 5 + 5);`?
|
||||
::diagnostic
|
||||
- [] 55
|
||||
- [x] 10
|
||||
- [] 5 + 5
|
||||
|
||||
> Penjelasan: 5 + 5 = 10.
|
||||
"""
|
||||
quiz = _parse_flashcards(md_text)
|
||||
assert len(quiz) == 1
|
||||
q = quiz[0]
|
||||
assert q['category'] == 'diagnostik'
|
||||
# Marker tidak boleh bocor ke teks yang dirender
|
||||
assert '::diagnostic' not in q['question']
|
||||
assert len(q['options']) == 3
|
||||
|
||||
|
||||
def test_diagnostic_marker_on_own_line_sets_category_flashcard():
|
||||
md_text = """### Apa fungsi `return 0;`?
|
||||
::diagnostic
|
||||
Program selesai dengan sukses.
|
||||
"""
|
||||
quiz = _parse_flashcards(md_text)
|
||||
assert len(quiz) == 1
|
||||
q = quiz[0]
|
||||
assert q['category'] == 'diagnostik'
|
||||
assert '::diagnostic' not in q['front']
|
||||
assert 'sukses' in q['back']
|
||||
|
||||
|
||||
def test_diagnostic_marker_inline_in_heading_does_not_set_category():
|
||||
"""Kontrak: marker wajib di baris sendiri — format inline (lama) diabaikan."""
|
||||
md_text = """### Apa output dari `printf("%d", 5 + 5);` <-- ::diagnostic
|
||||
- [] 55
|
||||
- [x] 10
|
||||
- [] 5 + 5
|
||||
"""
|
||||
quiz = _parse_flashcards(md_text)
|
||||
assert len(quiz) == 1
|
||||
q = quiz[0]
|
||||
assert q['category'] == 'evaluasi'
|
||||
# Konten heading tetap dipertahankan apa adanya (termasuk komentar)
|
||||
assert '::diagnostic' in q['question']
|
||||
|
||||
|
||||
def test_diagnostic_mixed_questions_categorized_independently():
|
||||
md_text = """### Soal evaluasi 1
|
||||
- [] a
|
||||
- [x] b
|
||||
|
||||
### Soal diagnostik 1
|
||||
::diagnostic
|
||||
- [] a
|
||||
- [x] b
|
||||
|
||||
### Soal evaluasi 2
|
||||
- [] a
|
||||
- [x] b
|
||||
"""
|
||||
quiz = _parse_flashcards(md_text)
|
||||
assert [q['category'] for q in quiz] == ['evaluasi', 'diagnostik', 'evaluasi']
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ def _payload(overrides: dict | None = None) -> dict:
|
|||
"occurred_at": "2026-08-09T14:04:44.000Z",
|
||||
"started_at": "2026-08-09T14:03:00.000Z",
|
||||
"visibility_event_count": 1,
|
||||
"answers": [],
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
|
|
|
|||
Loading…
Reference in New Issue