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:
parent
9bfecfbcab
commit
1f4217be76
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue