From 58cb35a01a311f5d63b8c2d3bedda29fa441e190 Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 15 Aug 2026 09:33:23 +0700 Subject: [PATCH] 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/ 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. --- examples/content/dasar/quiz_test.md | 7 +- frontend/src/lib/services/api.test.ts | 67 +++++---- frontend/src/lib/services/api.ts | 37 +++++ frontend/src/lib/services/quiz-session.ts | 43 +++++- frontend/src/lib/types/quiz.ts | 1 + .../src/routes/lesson/[slug]/QuizTab.svelte | 138 +++++++++++++++++- .../src/routes/lesson/[slug]/lesson.svelte.ts | 13 +- frontend/src/routes/progress/+page.svelte | 52 +++++++ .../0003_quiz_attempt_answers_json.py | 23 +++ routes/quiz_attempts.py | 67 +++++++++ services/lesson_service.py | 12 +- services/models.py | 7 + services/repositories.py | 15 ++ services/storage/postgres_backend.py | 36 +++++ services/tests/test_lesson_service_quiz.py | 74 ++++++++++ services/tests/test_quiz_attempt_routes.py | 1 + 16 files changed, 548 insertions(+), 45 deletions(-) create mode 100644 migrations/versions/0003_quiz_attempt_answers_json.py diff --git a/examples/content/dasar/quiz_test.md b/examples/content/dasar/quiz_test.md index 425303b..1d20f41 100644 --- a/examples/content/dasar/quiz_test.md +++ b/examples/content/dasar/quiz_test.md @@ -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--- \ No newline at end of file diff --git a/frontend/src/lib/services/api.test.ts b/frontend/src/lib/services/api.test.ts index e6365c6..ab2608c 100644 --- a/frontend/src/lib/services/api.test.ts +++ b/frontend/src/lib/services/api.test.ts @@ -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); }); diff --git a/frontend/src/lib/services/api.ts b/frontend/src/lib/services/api.ts index 9944412..56684b8 100644 --- a/frontend/src/lib/services/api.ts +++ b/frontend/src/lib/services/api.ts @@ -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('/quiz-attempts/submit', payload, customFetch); } +/** + * Fetch attempt kuis siswa untuk sebuah lesson (GET /quiz-attempts/). + * 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 { + const query = `token=${encodeURIComponent(token)}`; + return get(`/quiz-attempts/${lessonName}?${query}`, customFetch); +} + /** * Kirim attempt via `navigator.sendBeacon` (synchronous, fire-and-forget). * Dipakai untuk event lifecycle yang bisa membuat browser suspend diff --git a/frontend/src/lib/services/quiz-session.ts b/frontend/src/lib/services/quiz-session.ts index eabd8f5..cf0a64f 100644 --- a/frontend/src/lib/services/quiz-session.ts +++ b/frontend/src/lib/services/quiz-session.ts @@ -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, + }; } diff --git a/frontend/src/lib/types/quiz.ts b/frontend/src/lib/types/quiz.ts index 27d869a..0525993 100644 --- a/frontend/src/lib/types/quiz.ts +++ b/frontend/src/lib/types/quiz.ts @@ -7,6 +7,7 @@ export interface QuizOption { export interface QuizQuestion { id: string; type: 'flashcard' | 'mcq'; + category?: 'evaluasi' | 'diagnostik'; question?: string; front?: string; back?: string; diff --git a/frontend/src/routes/lesson/[slug]/QuizTab.svelte b/frontend/src/routes/lesson/[slug]/QuizTab.svelte index e57acb1..b3a072b 100644 --- a/frontend/src/routes/lesson/[slug]/QuizTab.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizTab.svelte @@ -1,8 +1,13 @@