fix(quiz/progress): perbaiki temuan audit — btn:disabled, css mati, indentasi
This commit is contained in:
parent
9aef612f01
commit
25269fca08
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
Loading…
Reference in New Issue