fix(quiz): enforce 75% passing threshold, sync server progress, and persist attempt review

- Only show celebration overlay and top bar completed checkmark if quiz score meets 75% threshold (>= 0.75) and exercises are done
- Return is_done, state, and composite_percent in POST /lesson-progress for exercise and quiz submissions
- Synchronize frontend LessonManager state with authoritative server is_done response
- Add has_quiz_attempt check in routes/lessons.py and token_service to recognize existing attempts even when composite state is in_progress
- Update QuizTab to display persistent attempt review, score status, and explanation instead of retake prompt on page load/refresh
- Make QuizQuestionView finish handler async, await finishQuiz(), and disable submission buttons to prevent navigation race conditions
- Update local ordered_lessons completion state dynamically upon passing
- Add unit and integration tests covering response payload attributes and has_quiz_attempt API field
- Bump APP_VERSION to 30
This commit is contained in:
a2nr 2026-09-09 07:33:52 +07:00
parent 9bfecfbcab
commit 1f4217be76
12 changed files with 197 additions and 37 deletions

View File

@ -173,6 +173,9 @@ export interface LessonProgressResponse {
idempotent?: boolean;
attempt_id?: string;
message?: string;
is_done?: boolean;
state?: string;
composite_percent?: number | null;
}
export async function submitLessonProgress(

View File

@ -47,5 +47,6 @@ export interface LessonContent {
evaluation_config: Record<string, any>;
quiz_data?: QuizQuestion[];
lesson_progress_status?: string;
has_quiz_attempt?: boolean;
hex_content?: string;
}

View File

@ -3,4 +3,4 @@
* Both Footer.svelte and service-worker.ts import from here.
* Bump this number after every feature/bugfix release.
*/
export const APP_VERSION = 29;
export const APP_VERSION = 30;

View File

@ -9,21 +9,28 @@
const questions = $derived(mgr.quizSession?.questions ?? []);
let submitting = $state(false);
function isAnswered(qId: string): boolean {
const a = mgr.quizSession?.answers[qId];
if (!a) return false;
return a.selectedOptionId !== null || a.acknowledged;
}
function handleFinish() {
if (!mgr.quizAllAnswered) return;
async function handleFinish() {
if (!mgr.quizAllAnswered || submitting) return;
if (
window.confirm(
'Apakah anda yakin untuk menyelesaikan kuis? Materi akan muncul kembali setelah ini.'
)
) {
mgr.finishQuiz();
submitting = true;
try {
await mgr.finishQuiz();
} finally {
submitting = false;
}
}
}
</script>
@ -48,7 +55,7 @@
{/each}
</div>
{/if}
<button type="button" class="btn-exit-quiz" onclick={() => mgr.submitQuiz()}>Keluar Kuis</button>
<button type="button" class="btn-exit-quiz" onclick={() => mgr.submitQuiz()} disabled={submitting}>Keluar Kuis</button>
</div>
{#if mgr.quizCurrentQuestion}
@ -87,9 +94,9 @@
type="button"
class="btn btn-success"
onclick={handleFinish}
disabled={!mgr.quizAllAnswered}
disabled={!mgr.quizAllAnswered || submitting}
>
Selesai Kuis
{submitting ? 'Menyimpan...' : 'Selesai Kuis'}
</button>
{/if}
</div>

View File

@ -16,11 +16,19 @@
let { mgr }: Props = $props();
const isLoggedIn = $derived($authLoggedIn);
// --- Review-after-refresh: fetch stored attempt (one-attempt → session lost) ---
let storedAttempt: LessonProgressFetchResponse | null = $state(null);
let reviewQuestions: QuizQuestion[] = $state([]);
let reviewAnswers: Record<string, QuizAnswer> = $state({});
// Gate 2: Quiz already completed (one-attempt enforcement)
const quizAlreadyCompleted = $derived(
!!mgr.data?.lesson_progress_status &&
(!!mgr.data?.lesson_progress_status &&
mgr.data.lesson_progress_status !== 'not_started' &&
mgr.data.lesson_progress_status !== ''
mgr.data.lesson_progress_status !== '') ||
!!mgr.data?.has_quiz_attempt ||
!!storedAttempt
);
const currentQuestion = $derived(mgr.currentQuizQuestion);
@ -31,11 +39,6 @@
// 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: LessonProgressFetchResponse | 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;
@ -43,7 +46,7 @@
try {
const resp = await fetchLessonProgress(lessonName, token);
const storedAnswers = resp.answers ?? [];
if (resp.success && storedAnswers.length) {
if (resp.success && (storedAnswers.length > 0 || resp.attempt_id)) {
storedAttempt = resp;
// Rekonstruksi question+answer maps dari stored attempt agar
// summary review per-soal bisa dirender setelah refresh.
@ -71,7 +74,7 @@
// Fetch attempt bila sudah selesai tapi session hilang (refresh/page-open)
onMount(() => {
const r = result;
if (!r && quizAlreadyCompleted && !mgr.quizSession) {
if (!r && (quizAlreadyCompleted || mgr.data?.has_quiz_attempt) && !mgr.quizSession) {
loadStoredAttempt();
}
});
@ -113,13 +116,24 @@
{#if quizAlreadyCompleted && !result}
{#if storedAttempt}
{@const s = storedAttempt}
<p class="score-display">Nilai Anda: <strong>{s.attempt_score ?? s.quiz_score_total != null ? `${s.quiz_score_earned ?? '-'}/${s.quiz_score_total}` : mgr.data?.lesson_progress_status}</strong></p>
{@const earned = s.quiz_score_earned ?? (s.attempt_score?.includes('/') ? parseInt(s.attempt_score.split('/')[0], 10) : null)}
{@const total = s.quiz_score_total ?? (s.attempt_score?.includes('/') ? parseInt(s.attempt_score.split('/')[1], 10) : null)}
<p class="score-display">Nilai Anda: <strong>{s.attempt_score ?? (total != null ? `${earned ?? '-'}/${total}` : mgr.data?.lesson_progress_status)}</strong></p>
{#if s.attempt_started_at || s.attempt_status}
<div class="score-breakdown">
{#if s.attempt_status}<span class="breakdown-item">Status: <strong>{s.attempt_status}</strong></span>{/if}
{#if s.attempt_score}<span class="breakdown-item">Skor: <strong>{s.attempt_score}</strong></span>{/if}
</div>
{/if}
{#if total != null && total > 0 && (earned ?? 0) / total < 0.75}
<div class="alert alert-warning">
Nilai Anda di bawah ambang batas 75%. Pelajari kembali topik di bawah ini.
</div>
{:else if total != null && total > 0}
<div class="alert alert-success">
Selamat! Anda telah memahami materi ini dengan baik.
</div>
{/if}
{:else}
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
{/if}

View File

@ -249,13 +249,32 @@ export class LessonManager {
async completeLesson(status = 'completed') {
if (this.lessonCompleted && status === 'completed') return;
this.showCelebration = true;
if (get(authLoggedIn)) {
const lessonName = this.slug.replace('.md', '');
await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' });
const res = await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' });
const isDone = res && typeof res.is_done === 'boolean' ? res.is_done : true;
this.lessonCompleted = isDone;
if (isDone) {
this.showCelebration = true;
}
lessonContext.update(ctx => ctx ? { ...ctx, completed: isDone } : ctx);
if (this.data?.ordered_lessons) {
const current = this.data.ordered_lessons.find(
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
);
if (current) current.completed = isDone;
}
} else {
this.showCelebration = true;
this.lessonCompleted = true;
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
if (this.data?.ordered_lessons) {
const current = this.data.ordered_lessons.find(
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
);
if (current) current.completed = true;
}
}
}
@ -384,10 +403,30 @@ export class LessonManager {
const payload = this.buildQuizAttemptPayload(reason);
if (!payload) return;
// Update status UI lokal (tanpa menulis progress ulang — endpoint
// attempt atomic yang menulis progress). Celebration hanya untuk
// penyelesaian normal, bukan exit penalti/termination.
this.markQuizCompletedLocally(reason === 'completed');
// Hitung perkiraan awal (optimistic) lulus/tidaknya kuis (ambang batas 75%)
const isQuizScorePassed =
reason === 'completed' && this.quizResult
? this.quizResult.totalMcq > 0
? this.quizResult.correctMcq / this.quizResult.totalMcq >= 0.75
: true
: false;
const hasExerciseTab =
this.data?.active_tabs?.some((t) =>
['c', 'python', 'circuit', 'flowchart', 'velxio'].includes(t)
) ?? false;
// Jika lesson juga memiliki latihan dan latihan belum selesai, kuis saja belum menyelesaikan lesson
const exerciseDone =
this.data?.lesson_progress_status?.startsWith('done:1:') ||
this.cPassed ||
this.pythonPassed ||
this.circuitPassed ||
this.flowchartPassed;
const optimisticLessonDone = isQuizScorePassed && (!hasExerciseTab || exerciseDone);
// Update status UI lokal secara optimis
this.markQuizCompletedLocally(optimisticLessonDone);
if (reason === 'focus_lost' || reason === 'page_unload') {
// Jangan mengandalkan await dari event lifecycle. Beacon pertama;
@ -395,21 +434,31 @@ export class LessonManager {
const ok = lessonProgressBeacon(payload);
if (!ok && typeof document !== 'undefined' && document.visibilityState === 'visible') {
try {
await submitLessonProgress(payload);
const res = await submitLessonProgress(payload);
if (this.data) this.data.has_quiz_attempt = true;
if (res && res.success && typeof res.is_done === 'boolean') {
this.markQuizCompletedLocally(res.is_done);
}
} catch {
/* best-effort saja — jangan memblokir UI */
}
}
return;
}
}
try {
await submitLessonProgress(payload);
try {
const res = await submitLessonProgress(payload);
if (this.data) this.data.has_quiz_attempt = true;
if (res && res.success && typeof res.is_done === 'boolean') {
this.markQuizCompletedLocally(res.is_done);
}
} catch {
// Fallback legacy: skor tetap tersimpan walau endpoint attempt
// tidak bisa dijangkau. Tidak membuat attempt kedua — endpoint
// attempt atomic; jalur ini hanya penyelamat last-resort.
await this.completeLesson(this.quizResult.statusString);
if (isQuizScorePassed) {
await this.completeLesson(this.quizResult.statusString);
}
}
}
@ -418,10 +467,16 @@ export class LessonManager {
* memanggil endpoint terpisah — attempt atomic /api/lesson-progress
* yang menulis progress.
*/
private markQuizCompletedLocally(celebrate: boolean) {
this.lessonCompleted = true;
if (celebrate) this.showCelebration = true;
lessonContext.update((ctx) => (ctx ? { ...ctx, completed: true } : ctx));
private markQuizCompletedLocally(passed: boolean) {
this.lessonCompleted = passed;
this.showCelebration = passed;
lessonContext.update((ctx) => (ctx ? { ...ctx, completed: passed } : ctx));
if (this.data?.ordered_lessons) {
const current = this.data.ordered_lessons.find(
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
);
if (current) current.completed = passed;
}
}
/**

View File

@ -84,9 +84,10 @@ def submit_lesson_progress():
has_exercise, has_quiz = get_lesson_components(lesson_name)
progress_rec = None
if progress_type == "exercise":
repo.set_exercise_passed(db, user_id=user.id, lesson_id=lesson.id)
repo.recompute_progress(
progress_rec = repo.recompute_progress(
db,
user_id=user.id,
lesson_id=lesson.id,
@ -97,7 +98,19 @@ def submit_lesson_progress():
done_min_percent=LESSON_DONE_MIN_PERCENT,
)
db.commit()
return jsonify({"success": True, "idempotent": False})
return jsonify(
{
"success": True,
"idempotent": False,
"is_done": progress_rec.state == "done" if progress_rec else False,
"state": progress_rec.state if progress_rec else "not_started",
"composite_percent": (
round(progress_rec.composite_percent, 2)
if progress_rec and progress_rec.composite_percent is not None
else None
),
}
)
# ── progress_type == 'quiz' ── mirror submit_quiz_attempt ─────
attempt_id = str(data.get("attempt_id") or "").strip()
@ -199,7 +212,7 @@ def submit_lesson_progress():
quiz_earned=earned,
quiz_total=total,
)
repo.recompute_progress(
progress_rec = repo.recompute_progress(
db,
user_id=user.id,
lesson_id=lesson.id,
@ -209,6 +222,8 @@ def submit_lesson_progress():
quiz_weight=LESSON_QUIZ_WEIGHT,
done_min_percent=LESSON_DONE_MIN_PERCENT,
)
else:
progress_rec = repo.get_progress(db, user_id=user.id, lesson_id=lesson.id)
db.commit()
except IntegrityError:
db.rollback()
@ -231,6 +246,13 @@ def submit_lesson_progress():
"success": True,
"idempotent": not created,
"attempt_id": attempt.id,
"is_done": progress_rec.state == "done" if progress_rec else False,
"state": progress_rec.state if progress_rec else "not_started",
"composite_percent": (
round(progress_rec.composite_percent, 2)
if progress_rec and progress_rec.composite_percent is not None
else None
),
}
)

View File

@ -19,7 +19,7 @@ from services.lesson_service import (
render_home_content,
find_lesson_file,
)
from services.token_service import get_student_progress
from services.token_service import get_student_progress, has_quiz_attempt
lessons_bp = Blueprint('lessons', __name__)
@ -142,12 +142,15 @@ def api_lesson(filename):
progress = None
lesson_completed = False
lesson_progress_status = ''
lesson_slug = full_filename.replace('.md', '')
user_has_quiz_attempt = False
if token:
progress = get_student_progress(token)
if progress:
status = progress.get(full_filename.replace('.md', ''), '')
status = progress.get(lesson_slug, '')
lesson_progress_status = status
lesson_completed = status not in (None, '', 'not_started')
user_has_quiz_attempt = has_quiz_attempt(token, lesson_slug)
# Detect sub-home for this lesson's folder (untuk sidebar & navigasi)
sub_home_path, sub_home_folder = find_sub_home_for_lesson(file_path)
@ -262,6 +265,7 @@ def api_lesson(filename):
'lesson_progress_status': lesson_progress_status,
'lesson_title': current_lesson_meta['title'] if current_lesson_meta else full_filename.replace('.md', '').replace('_', ' ').title(),
'lesson_completed': lesson_completed,
'has_quiz_attempt': user_has_quiz_attempt,
'locked': is_locked,
'missing_prerequisites': missing_prereqs,
'prev_lesson': prev_lesson,

View File

@ -99,6 +99,26 @@ def get_student_progress(token):
db.close()
def has_quiz_attempt(token, lesson_name):
"""Cek apakah siswa sudah memiliki rekaman attempt kuis untuk lesson ini."""
if not token or SessionLocal is None:
return False
db = SessionLocal()
try:
user = find_user_by_raw_token(db, token)
if user is None:
return False
lesson = get_lesson_by_slug(db, lesson_name)
if lesson is None:
return False
attempt = repo.get_quiz_attempt_for_user_lesson(
db, user_id=user.id, lesson_id=lesson.id
)
return attempt is not None
finally:
db.close()
def reset_progress(student_id, lesson_name):
"""Reset via student_id = user.id (PG) — teacher tidak perlu token siswa.

View File

@ -129,6 +129,25 @@ def test_quiz_submit_creates_attempt(client):
assert body["success"] is True
assert body["idempotent"] is False
assert "attempt_id" in body
assert body["is_done"] is True
assert body["state"] == "done"
assert body["composite_percent"] == 100.0
def test_quiz_submit_below_threshold_marks_in_progress(client, monkeypatch):
import routes.lesson_progress as lp
monkeypatch.setattr(lp, "get_lesson_components", lambda name: (False, True))
resp = client.post(
"/lesson-progress",
json=_payload({"status": "submitted", "termination_reason": None, "score": "1/4"}),
)
assert resp.status_code == 200
body = resp.get_json()
assert body["success"] is True
assert body["is_done"] is False
assert body["state"] == "in_progress"
assert body["composite_percent"] == 25.0
def test_quiz_retry_same_attempt_id_idempotent(client):

View File

@ -303,3 +303,13 @@ def test_lesson_detail_missing_prerequisites_with_score(client, tmp_path, monkey
data = resp.get_json()
assert data["locked"] is True
assert data["missing_prerequisites"] == ["dasar"]
assert "has_quiz_attempt" in data
def test_lesson_detail_has_quiz_attempt_field(client, tmp_path, monkeypatch):
_make_bab_with_prereq(tmp_path, monkeypatch, "- [Dasar](lesson/dasar.md)")
from routes import lessons as lessons_routes
monkeypatch.setattr(lessons_routes, "has_quiz_attempt", lambda token, slug: True)
resp = client.get("/lesson/lanjutan.json?token=siswa1")
assert resp.status_code == 200
assert resp.get_json()["has_quiz_attempt"] is True

View File

@ -27,6 +27,11 @@ def get_student_progress(token):
return _backend().get_student_progress(token)
def has_quiz_attempt(token, lesson_name):
"""Cek apakah siswa sudah memiliki rekaman attempt kuis untuk lesson."""
return _backend().has_quiz_attempt(token, lesson_name)
def reset_student_progress(student_id, lesson_name):
"""Reset progress siswa by student_id (id anonim dari report — bukan token)."""
return _backend().reset_progress(student_id, lesson_name)