fix(quiz/progress): perbaiki temuan audit — btn:disabled, css mati, indentasi

This commit is contained in:
a2nr 2026-08-17 09:10:10 +07:00
parent 9aef612f01
commit 25269fca08
5 changed files with 225 additions and 190 deletions

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
addStudent,
bulkDeleteStudents,
exportStudentsCsv,
filenameFromDisposition,
@ -201,3 +202,38 @@ describe('bulkDeleteStudents', () => {
expect(res.deleted_count).toBe(2);
});
});
describe('addStudent', () => {
it('mengirim POST ke /api/students/add dengan nama_siswa & token', async () => {
const { calls, customFetch } = captureFetch(() =>
jsonResponse({ success: true, student_id: 'abc-123', nama_siswa: 'Andi' })
);
const result = await addStudent('Andi', 'TOKEN_ANDI_001', customFetch);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/students/add');
expect(calls[0].init.method).toBe('POST');
const body = JSON.parse(String(calls[0].init.body));
expect(body).toEqual({ nama_siswa: 'Andi', token: 'TOKEN_ANDI_001' });
expect(result.success).toBe(true);
expect(result.student_id).toBe('abc-123');
expect(result.nama_siswa).toBe('Andi');
});
it('meneruskan error terstruktur saat backend menolak (mis. token duplikat)', async () => {
const { customFetch } = captureFetch(() =>
jsonResponse(
{
success: false,
message: 'Token sudah terdaftar',
errors: ['Baris 1: token sudah terdaftar di database']
},
409
)
);
const result = await addStudent('Siswa Lain', 'TOKEN_DIPAKAI', customFetch);
expect(result.success).toBe(false);
expect(result.errors).toContain('Baris 1: token sudah terdaftar di database');
});
});

View File

@ -431,7 +431,6 @@
.btn { padding: 0.6rem 1.2rem; border-radius: 8px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.2s; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #339af0; color: white; }
.btn-success { background: #40c057; color: white; }
.btn-lg { padding: 1rem 2rem; font-size: 1.1rem; }
.btn-exit-quiz { background: none; border: none; color: var(--color-danger, #dc3545); text-decoration: underline; font-size: 0.85rem; cursor: pointer; }

View File

@ -452,11 +452,6 @@
flex-wrap: wrap;
}
.quiz-question-counter {
font-weight: 700;
color: var(--color-primary);
}
.quiz-question-header .btn-exit-quiz {
background: none;
border: none;
@ -550,6 +545,11 @@
color: var(--color-text);
}
.quiz-controls .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 600px) {
.quiz-question-view {
padding: 1rem 0;

View File

@ -160,110 +160,110 @@ def reset_progress(student_id, lesson_name):
def get_all_students_progress(all_lessons_func):
"""Semua user (siswa + guru) + ordered_lessons dari registry.
"""Semua user (siswa + guru) + ordered_lessons dari registry.
Guru ikut ditampilkan sebagai row di report /progress agar bisa di-review
materinya (dan di-reset progresnya sendiri). Field `role` menandai
apakah row tersebut guru atau siswa. Student dict TIDAK mengandung token
mentah (kontrak keamanan).
"""
if SessionLocal is None:
return [], []
db = SessionLocal()
try:
lessons = list_lessons(db)
all_lessons_dict = {}
for lesson in all_lessons_func():
lesson_key = lesson['filename'].replace('.md', '')
all_lessons_dict[lesson_key] = lesson
Guru ikut ditampilkan sebagai row di report /progress agar bisa di-review
materinya (dan di-reset progresnya sendiri). Field `role` menandai
apakah row tersebut guru atau siswa. Student dict TIDAK mengandung token
mentah (kontrak keamanan).
"""
if SessionLocal is None:
return [], []
db = SessionLocal()
try:
lessons = list_lessons(db)
all_lessons_dict = {}
for lesson in all_lessons_func():
lesson_key = lesson['filename'].replace('.md', '')
all_lessons_dict[lesson_key] = lesson
ordered_lessons = []
for lesson in lessons:
slug = lesson.slug
if slug in all_lessons_dict:
ordered_lessons.append(all_lessons_dict[slug])
else:
ordered_lessons.append({
'filename': f"{slug}.md",
'title': lesson.title,
'description': 'Lesson information not available',
})
ordered_lessons = []
for lesson in lessons:
slug = lesson.slug
if slug in all_lessons_dict:
ordered_lessons.append(all_lessons_dict[slug])
else:
ordered_lessons.append({
'filename': f"{slug}.md",
'title': lesson.title,
'description': 'Lesson information not available',
})
# Semua user (tanpa filter role) — guru ikut sebagai row, urutan
# deterministik (created_at, id) agar stable untuk UI/export.
users = list(
db.scalars(
select(User)
.order_by(User.created_at, User.id)
)
)
students = []
for user in users:
rows = {p.lesson_id: p for p in list_progress_for_user(db, user_id=user.id)}
attempts = {
a.lesson_id: a for a in list_quiz_attempts_for_user(db, user_id=user.id)
}
student = {'nama_siswa': user.display_name, 'id': user.id, 'role': user.role}
for lesson in lessons:
student[lesson.slug] = _status_to_string(rows.get(lesson.id))
# Metadata anti-cheat — field TERPISAH, tidak mengubah kontrak
# status lama. `has_violation` hanya untuk reason focus_lost.
attempt = attempts.get(lesson.id)
student[f"{lesson.slug}_attempt_status"] = attempt.status if attempt else ""
student[f"{lesson.slug}_termination_reason"] = (
attempt.termination_reason if attempt else ""
)
student[f"{lesson.slug}_has_violation"] = bool(
attempt and attempt.termination_reason == "focus_lost"
)
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. Sama seperti FE calculateQuizResult: breakdown
# HANYA dihitung untuk soal MCQ; flashcard netral (tidak masuk eval
# maupun diag) supaya penyebut eval = jumlah MCQ evaluasi, konsisten
# dengan skor resmi (statusString) yang juga cuma MCQ.
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:
# Flashcard netral: abaikan dari breakdown eval/diag.
if ans.get('type') == 'flashcard':
continue
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('type') != 'flashcard'
and 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
finally:
db.close()
# Semua user (tanpa filter role) — guru ikut sebagai row, urutan
# deterministik (created_at, id) agar stable untuk UI/export.
users = list(
db.scalars(
select(User)
.order_by(User.created_at, User.id)
)
)
students = []
for user in users:
rows = {p.lesson_id: p for p in list_progress_for_user(db, user_id=user.id)}
attempts = {
a.lesson_id: a for a in list_quiz_attempts_for_user(db, user_id=user.id)
}
student = {'nama_siswa': user.display_name, 'id': user.id, 'role': user.role}
for lesson in lessons:
student[lesson.slug] = _status_to_string(rows.get(lesson.id))
# Metadata anti-cheat — field TERPISAH, tidak mengubah kontrak
# status lama. `has_violation` hanya untuk reason focus_lost.
attempt = attempts.get(lesson.id)
student[f"{lesson.slug}_attempt_status"] = attempt.status if attempt else ""
student[f"{lesson.slug}_termination_reason"] = (
attempt.termination_reason if attempt else ""
)
student[f"{lesson.slug}_has_violation"] = bool(
attempt and attempt.termination_reason == "focus_lost"
)
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. Sama seperti FE calculateQuizResult: breakdown
# HANYA dihitung untuk soal MCQ; flashcard netral (tidak masuk eval
# maupun diag) supaya penyebut eval = jumlah MCQ evaluasi, konsisten
# dengan skor resmi (statusString) yang juga cuma MCQ.
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:
# Flashcard netral: abaikan dari breakdown eval/diag.
if ans.get('type') == 'flashcard':
continue
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('type') != 'flashcard'
and 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
finally:
db.close()

View File

@ -132,92 +132,92 @@ def test_raw_token_not_in_logs(client, caplog):
def test_pg_report_includes_teacher_and_students(client):
"""Report PostgreSQL memuat semua user: guru + siswa sebagai row terpisah.
"""Report PostgreSQL memuat semua user: guru + siswa sebagai row terpisah.
Data (Pak Guru/Budi/Siti + lesson hello_world) berasal dari fixture
`seed_demo_users` tidak di-seed ulang di sini karena token_hash
deterministik (HMAC+pepper) menabrak unique constraint.
"""
client.set_cookie("student_token", TEACHER_TOKEN)
resp = client.get("/progress-report.json")
assert resp.status_code == 200
data = resp.get_json()
names = [st["nama_siswa"] for st in data["students"]]
assert "Pak Guru" in names
assert "Budi Santoso" in names
assert "Siti Aminah" in names
# guru punya role teacher, siswa punya role student
roles = {st["nama_siswa"]: st["role"] for st in data["students"]}
assert roles["Pak Guru"] == "teacher"
assert roles["Budi Santoso"] == "student"
# field yang dipertahankan untuk frontend
for st in data["students"]:
assert st["id"]
assert "completed_count" in st
assert "hello_world" in st
Data (Pak Guru/Budi/Siti + lesson hello_world) berasal dari fixture
`seed_demo_users` tidak di-seed ulang di sini karena token_hash
deterministik (HMAC+pepper) menabrak unique constraint.
"""
client.set_cookie("student_token", TEACHER_TOKEN)
resp = client.get("/progress-report.json")
assert resp.status_code == 200
data = resp.get_json()
names = [st["nama_siswa"] for st in data["students"]]
assert "Pak Guru" in names
assert "Budi Santoso" in names
assert "Siti Aminah" in names
# guru punya role teacher, siswa punya role student
roles = {st["nama_siswa"]: st["role"] for st in data["students"]}
assert roles["Pak Guru"] == "teacher"
assert roles["Budi Santoso"] == "student"
# field yang dipertahankan untuk frontend
for st in data["students"]:
assert st["id"]
assert "completed_count" in st
assert "hello_world" in st
def test_quiz_breakdown_excludes_flashcards(client):
"""Breakdown eval/diag HANYA menghitung MCQ — flashcard netral.
"""Breakdown eval/diag HANYA menghitung MCQ — flashcard netral.
Regresi guard untuk bug `eval:4/6`: flashcard ber-kategori 'evaluasi'
tidak boleh menambah penyebut eval (yang seharusnya = jumlah MCQ evaluasi).
"""
from services import token_service as ts
Regresi guard untuk bug `eval:4/6`: flashcard ber-kategori 'evaluasi'
tidak boleh menambah penyebut eval (yang seharusnya = jumlah MCQ evaluasi).
"""
from services import token_service as ts
# Seed attempt untuk Budi: 2 MCQ evaluasi benar + 1 flashcard 'evaluasi'.
# Skor resmi (statusString) hanya MCQ → "2/2". Breakdown eval harus 2/2,
# bukan 2/3 (yang akan terjadi kalau flashcard ikut dihitung).
answers = [
{
"question_id": "mcq-1",
"selected_option_id": "o-a",
"is_correct": True,
"category": "evaluasi",
"type": "mcq",
},
{
"question_id": "mcq-2",
"selected_option_id": "o-b",
"is_correct": True,
"category": "evaluasi",
"type": "mcq",
},
{
"question_id": "fc-1",
"selected_option_id": None,
"is_correct": False,
"category": "evaluasi",
"type": "flashcard",
},
]
import uuid
# Seed attempt untuk Budi: 2 MCQ evaluasi benar + 1 flashcard 'evaluasi'.
# Skor resmi (statusString) hanya MCQ → "2/2". Breakdown eval harus 2/2,
# bukan 2/3 (yang akan terjadi kalau flashcard ikut dihitung).
answers = [
{
"question_id": "mcq-1",
"selected_option_id": "o-a",
"is_correct": True,
"category": "evaluasi",
"type": "mcq",
},
{
"question_id": "mcq-2",
"selected_option_id": "o-b",
"is_correct": True,
"category": "evaluasi",
"type": "mcq",
},
{
"question_id": "fc-1",
"selected_option_id": None,
"is_correct": False,
"category": "evaluasi",
"type": "flashcard",
},
]
import uuid
ts.update_student_progress(STUDENT_TOKEN, "hello_world", "2/2")
resp = client.post(
"/quiz-attempts/submit",
json={
"attempt_id": str(uuid.uuid4()),
"token": STUDENT_TOKEN,
"lesson_name": "hello_world",
"status": "submitted",
"termination_reason": None,
"score": "2/2",
"occurred_at": "2026-01-01T00:00:00Z",
"started_at": "2026-01-01T00:00:00Z",
"visibility_event_count": 0,
"answers": answers,
},
)
assert resp.status_code == 200
ts.update_student_progress(STUDENT_TOKEN, "hello_world", "2/2")
resp = client.post(
"/quiz-attempts/submit",
json={
"attempt_id": str(uuid.uuid4()),
"token": STUDENT_TOKEN,
"lesson_name": "hello_world",
"status": "submitted",
"termination_reason": None,
"score": "2/2",
"occurred_at": "2026-01-01T00:00:00Z",
"started_at": "2026-01-01T00:00:00Z",
"visibility_event_count": 0,
"answers": answers,
},
)
assert resp.status_code == 200
client.set_cookie("student_token", TEACHER_TOKEN)
report = client.get("/progress-report.json")
assert report.status_code == 200
students = {st["nama_siswa"]: st for st in report.get_json()["students"]}
budi = students["Budi Santoso"]
assert budi["hello_world"] == "2/2"
assert budi["hello_world_eval"] == "2/2", (
f"eval breakdown harus 2/2 (MCQ saja), flashcard netral — dapat {budi['hello_world_eval']!r}"
)
assert budi["hello_world_diag"] == "0/0", "diag 0/0 (tidak ada MCQ diagnostik)"
client.set_cookie("student_token", TEACHER_TOKEN)
report = client.get("/progress-report.json")
assert report.status_code == 200
students = {st["nama_siswa"]: st for st in report.get_json()["students"]}
budi = students["Budi Santoso"]
assert budi["hello_world"] == "2/2"
assert budi["hello_world_eval"] == "2/2", (
f"eval breakdown harus 2/2 (MCQ saja), flashcard netral — dapat {budi['hello_world_eval']!r}"
)
assert budi["hello_world_diag"] == "0/0", "diag 0/0 (tidak ada MCQ diagnostik)"