feat(student-management): sync imported progress with composite model and add inline expected format to CSV headers
- Sync imported progress (completed, scored, done, reset) to composite progress model and recompute composite_percent - Prevent caching of /progress-report.json with Cache-Control: no-store and frontend cache-busting timestamp - Provide inline expected format guides in exported CSV lesson headers (e.g. [completed], [<earned>/<total>], [done:<1|0>:<earned>/N]) - Support robust slug extraction from formatted headers in CSV roundtrip import with full backward compatibility - Add example composite lesson (exercise + quiz) in examples/content/dasar/composite_test.md - Add comprehensive unit and integration tests covering progress synchronization, header formatting, and roundtrip workflows
This commit is contained in:
parent
17713303bb
commit
9bfecfbcab
|
|
@ -0,0 +1,108 @@
|
|||
---LESSON_INFO---
|
||||
**Learning Objectives:**
|
||||
- Memahami konsep materi komposit di Elemes (latihan koding + kuis)
|
||||
- Menyelesaikan latihan program C/Python dan kuis evaluasi pemahaman
|
||||
**Prerequisites:**
|
||||
- variabel
|
||||
---END_LESSON_INFO---
|
||||
|
||||
# Uji Coba Komposit (Latihan dan Kuis)
|
||||
|
||||
Pelajaran ini merupakan contoh materi **komposit**, yaitu materi pembelajaran yang menggabungkan:
|
||||
1. **Latihan Pemrograman (Exercise):** Menulis kode program langsung di editor dan memverifikasi keluarannya.
|
||||
2. **Kuis Evaluasi (Quiz):** Menjawab pertanyaan pilihan ganda terkait materi.
|
||||
|
||||
### Perhitungan Nilai Komposit
|
||||
Nilai akhir siswa dihitung dari kombinasi kedua komponen:
|
||||
- Bobot Latihan: 70%
|
||||
- Bobot Kuis: 30%
|
||||
- Ambang Tuntas (*Done*): $\ge$ 75%
|
||||
|
||||
---EXERCISE---
|
||||
### Latihan: Pengecekan Angka Positif
|
||||
Buatlah program yang memeriksa nilai variabel `angka`. Jika `angka > 0`, cetak:
|
||||
```
|
||||
Positif
|
||||
```
|
||||
|
||||
---INITIAL_CODE---
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
int angka = 10;
|
||||
// Tulis logika percabangan if di bawah ini:
|
||||
if (angka > 0) {
|
||||
printf("Positif\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
---END_INITIAL_CODE---
|
||||
|
||||
---EXPECTED_OUTPUT---
|
||||
Positif
|
||||
---END_EXPECTED_OUTPUT---
|
||||
|
||||
---INITIAL_PYTHON---
|
||||
angka = 10
|
||||
# Tulis logika percabangan if di bawah ini:
|
||||
if angka > 0:
|
||||
print("Positif")
|
||||
---END_INITIAL_PYTHON---
|
||||
|
||||
---EXPECTED_OUTPUT_PYTHON---
|
||||
Positif
|
||||
---END_EXPECTED_OUTPUT_PYTHON---
|
||||
|
||||
---SOLUTION_CODE---
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
int angka = 10;
|
||||
if (angka > 0) {
|
||||
printf("Positif\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
---END_SOLUTION_CODE---
|
||||
|
||||
---SOLUTION_PYTHON---
|
||||
angka = 10
|
||||
if angka > 0:
|
||||
print("Positif")
|
||||
---END_SOLUTION_PYTHON---
|
||||
|
||||
---KEY_TEXT---
|
||||
if
|
||||
printf
|
||||
print
|
||||
---END_KEY_TEXT---
|
||||
|
||||
---QUIZ_FLASHCARD---
|
||||
### Manakah sintaks penulisan kondisi `if` yang benar dalam bahasa C?
|
||||
- [x] `if (kondisi) { ... }`
|
||||
- [] `if kondisi then { ... }`
|
||||
- [] `if [kondisi] { ... }`
|
||||
- [] `if {kondisi} then { ... }`
|
||||
> Penjelasan: Dalam bahasa C, ekspresi logika kondisi pada `if` wajib berada di dalam tanda kurung biasa `( )`.
|
||||
|
||||
### Apa keluaran dari kode berikut jika `x = 5`?
|
||||
```c
|
||||
if (x > 10) {
|
||||
printf("Besar\n");
|
||||
} else {
|
||||
printf("Kecil\n");
|
||||
}
|
||||
```
|
||||
- [] Besar
|
||||
- [x] Kecil
|
||||
- [] Error sintaks
|
||||
- [] Tidak ada keluaran
|
||||
> Penjelasan: Karena `5 > 10` bernilai salah (false), maka blok `else` yang akan dieksekusi, menghasilkan output "Kecil".
|
||||
|
||||
### Berapa bobot penilaian komponen latihan koding dalam penentuan nilai komposit standar Elemes?
|
||||
- [] 30%
|
||||
- [] 50%
|
||||
- [x] 70%
|
||||
- [] 100%
|
||||
> Penjelasan: Secara default pada konfigurasi Elemes, komponen latihan koding memiliki bobot 70% dan kuis memiliki bobot 30%.
|
||||
---END_QUIZ_FLASHCARD---
|
||||
|
|
@ -9,5 +9,6 @@ Materi fundamental untuk memulai belajar pemrograman C.
|
|||
4. [Uji Coba Kuis](lesson/quiz_test.md)
|
||||
5. [Uji Coba LaTeX](lesson/latex_test.md)
|
||||
6. [Uji Coba Slide](lesson/test_slides.md)
|
||||
7. [Uji Coba Komposit](lesson/composite_test.md)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/progress-report.json?token=${encodeURIComponent($authToken)}`);
|
||||
const res = await fetch(`/api/progress-report.json?token=${encodeURIComponent($authToken)}&_t=${Date.now()}`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
const data = await res.json();
|
||||
students = data.students ?? [];
|
||||
lessons = data.lessons ?? [];
|
||||
|
|
@ -286,6 +288,7 @@
|
|||
</td>
|
||||
{#each lessons as lesson}
|
||||
{@const key = lesson.filename.replace('.md', '')}
|
||||
{@const status = student[key]}
|
||||
{@const composite = student[key + '_composite']}
|
||||
{@const exercisePassed = student[key + '_exercise_passed']}
|
||||
{@const quizScore = student[key + '_quiz_score']}
|
||||
|
|
@ -305,6 +308,10 @@
|
|||
{#if quizScore}
|
||||
<span class="badge quiz-score" title="Skor quiz">{quizScore}</span>
|
||||
{/if}
|
||||
{:else if status === 'completed'}
|
||||
<span class="badge exercise-done" title="Selesai">✓</span>
|
||||
{:else if status && status !== 'not_started'}
|
||||
<span class="badge score" title="Status progres">{status}</span>
|
||||
{:else}
|
||||
<span class="badge empty">—</span>
|
||||
{/if}
|
||||
|
|
@ -333,7 +340,7 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if composite !== null && composite !== undefined}
|
||||
{#if (composite !== null && composite !== undefined) || (status && status !== 'not_started')}
|
||||
<button
|
||||
class="btn-reset-mini"
|
||||
onclick={() => handleReset(student.id as string, key, student.nama_siswa)}
|
||||
|
|
|
|||
|
|
@ -85,10 +85,12 @@ def api_progress_report():
|
|||
get_lessons_with_learning_objectives,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
resp = jsonify({
|
||||
'students': all_students_progress,
|
||||
'lessons': [{'filename': l['filename'], 'title': l['title']} for l in ordered_lessons],
|
||||
})
|
||||
resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
return resp
|
||||
|
||||
|
||||
@progress_bp.route('/progress-report/export-csv')
|
||||
|
|
|
|||
|
|
@ -1304,3 +1304,35 @@ def get_lesson_components(lesson_name: str) -> tuple[bool, bool]:
|
|||
has_quiz = "quiz" in tabs
|
||||
return (has_exercise, has_quiz)
|
||||
|
||||
|
||||
def get_lesson_quiz_total(lesson_name: str) -> int | None:
|
||||
"""Kembalikan jumlah soal kuis untuk lesson jika ada, atau None."""
|
||||
file_path = find_lesson_file(f"{lesson_name}.md")
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
parsed = render_markdown_content(file_path)
|
||||
quiz_data = parsed.get("quiz_data") or []
|
||||
return len(quiz_data) if quiz_data else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_lesson_expected_format(lesson_name: str) -> str:
|
||||
"""Tentukan panduan expected format kolom lesson berdasarkan komponennya.
|
||||
|
||||
- Exercise only: 'completed'
|
||||
- Quiz only: '<earned>/N' jika total soal diketahui, atau '<earned>/<total>'
|
||||
- Exercise + Quiz: 'done:<1|0>:<earned>/N' (atau '<total>')
|
||||
- Reading only: 'completed'
|
||||
"""
|
||||
has_exercise, has_quiz = get_lesson_components(lesson_name)
|
||||
quiz_total = get_lesson_quiz_total(lesson_name) if has_quiz else None
|
||||
if has_exercise and has_quiz:
|
||||
q_fmt = f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
|
||||
return f"done:<1|0>:{q_fmt}"
|
||||
if has_quiz:
|
||||
return f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
|
||||
return "completed"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from sqlalchemy.orm import Session
|
|||
from config import LESSON_EXERCISE_WEIGHT, LESSON_QUIZ_WEIGHT, LESSON_DONE_MIN_PERCENT
|
||||
from services.models import AccessToken, ContentDraft, Lesson, QuizAttempt, StudentProgress, User
|
||||
from services.evaluation import compute_composite
|
||||
from services.lesson_service import get_lesson_components
|
||||
from services.lesson_service import get_lesson_components, get_lesson_quiz_total
|
||||
from services.progress_status import ParsedProgress
|
||||
from services.student_roundtrip import (
|
||||
RoundTripImportError,
|
||||
|
|
@ -483,6 +483,24 @@ def list_students_for_export(
|
|||
)
|
||||
|
||||
|
||||
def get_lesson_expected_format(slug: str) -> str:
|
||||
"""Tentukan panduan expected format kolom lesson berdasarkan komponennya.
|
||||
|
||||
- Exercise only: 'completed'
|
||||
- Quiz only: '<earned>/N' jika total soal diketahui, atau '<earned>/<total>'
|
||||
- Exercise + Quiz: 'done:<1|0>:<earned>/N' (atau '<total>')
|
||||
- Reading only: 'completed'
|
||||
"""
|
||||
has_exercise, has_quiz = get_lesson_components(slug)
|
||||
quiz_total = get_lesson_quiz_total(slug) if has_quiz else None
|
||||
if has_exercise and has_quiz:
|
||||
q_fmt = f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
|
||||
return f"done:<1|0>:{q_fmt}"
|
||||
if has_quiz:
|
||||
return f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
|
||||
return "completed"
|
||||
|
||||
|
||||
def export_students_csv(
|
||||
db: Session,
|
||||
student_ids: list[str] | None = None,
|
||||
|
|
@ -490,10 +508,15 @@ def export_students_csv(
|
|||
"""Export siswa (selection atau seluruh) + progress → CSV round-trip bytes.
|
||||
|
||||
Kolom token selalu kosong; teacher tidak pernah ikut; export tidak mengubah DB.
|
||||
Header kolom lesson dilengkapi format yang diharapkan (mis. 'hello_world [completed]').
|
||||
"""
|
||||
users = list_students_for_export(db, student_ids)
|
||||
lessons = list_active_lessons(db)
|
||||
lesson_slugs = [lesson.slug for lesson in lessons]
|
||||
lesson_headers = {
|
||||
lesson.slug: f"{lesson.slug} [{get_lesson_expected_format(lesson.slug)}]"
|
||||
for lesson in lessons
|
||||
}
|
||||
|
||||
rows: list[StudentRoundTripRow] = []
|
||||
for user in users:
|
||||
|
|
@ -527,7 +550,7 @@ def export_students_csv(
|
|||
progress=progress,
|
||||
)
|
||||
)
|
||||
return serialize_export_csv(rows, lesson_slugs)
|
||||
return serialize_export_csv(rows, lesson_slugs, lesson_headers=lesson_headers)
|
||||
|
||||
|
||||
def find_student_import_conflicts(
|
||||
|
|
@ -707,6 +730,47 @@ def run_student_import(
|
|||
quiz_weight=LESSON_QUIZ_WEIGHT,
|
||||
done_min_percent=LESSON_DONE_MIN_PERCENT,
|
||||
)
|
||||
elif parsed.state == "completed":
|
||||
has_exercise, has_quiz = get_lesson_components(slug)
|
||||
progress = set_progress(
|
||||
db,
|
||||
user_id=user.id,
|
||||
lesson_id=lesson.id,
|
||||
state="completed",
|
||||
)
|
||||
if progress is not None:
|
||||
if has_exercise:
|
||||
progress.exercise_passed = True
|
||||
progress.composite_percent = 100.0
|
||||
db.flush()
|
||||
elif parsed.state == "scored":
|
||||
has_exercise, has_quiz = get_lesson_components(slug)
|
||||
effective_has_quiz = has_quiz or (
|
||||
parsed.score_total is not None and parsed.score_total > 0
|
||||
)
|
||||
progress = set_progress(
|
||||
db,
|
||||
user_id=user.id,
|
||||
lesson_id=lesson.id,
|
||||
state="scored",
|
||||
score_earned=parsed.score_earned,
|
||||
score_total=parsed.score_total,
|
||||
)
|
||||
if progress is not None:
|
||||
progress.quiz_score_earned = parsed.score_earned
|
||||
progress.quiz_score_total = parsed.score_total
|
||||
result = compute_composite(
|
||||
exercise_passed=progress.exercise_passed,
|
||||
quiz_earned=progress.quiz_score_earned,
|
||||
quiz_total=progress.quiz_score_total,
|
||||
has_exercise=has_exercise,
|
||||
has_quiz=effective_has_quiz,
|
||||
exercise_weight=LESSON_EXERCISE_WEIGHT,
|
||||
quiz_weight=LESSON_QUIZ_WEIGHT,
|
||||
done_min_percent=LESSON_DONE_MIN_PERCENT,
|
||||
)
|
||||
progress.composite_percent = result.composite_percent
|
||||
db.flush()
|
||||
elif parsed.state == "reset":
|
||||
set_progress(db, user_id=user.id, lesson_id=lesson.id, state="not_started")
|
||||
progress_reset += 1
|
||||
|
|
|
|||
|
|
@ -172,6 +172,40 @@ def _row_is_empty(record: dict) -> bool:
|
|||
return all(not (v or "").strip() for v in record.values())
|
||||
|
||||
|
||||
def extract_slug_from_header(header: str, active_slugs: set[str] | None = None) -> str:
|
||||
"""Ekstrak slug lesson canonical dari nama kolom header.
|
||||
|
||||
Mendukung format:
|
||||
- Bare slug: "hello_world"
|
||||
- Slug + expected format: "hello_world [completed]", "quiz_test [<earned>/4]"
|
||||
- Title + (slug) + format: "Hello World (hello_world) [completed]"
|
||||
- Title + (slug): "Hello World (hello_world)"
|
||||
"""
|
||||
raw = (header or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if active_slugs and raw in active_slugs:
|
||||
return raw
|
||||
|
||||
# 1. Hapus kurung siku [expected format] di akhir bila ada
|
||||
base = raw
|
||||
if base.endswith("]") and "[" in base:
|
||||
base = base[: base.rfind("[")].strip()
|
||||
|
||||
if active_slugs and base in active_slugs:
|
||||
return base
|
||||
|
||||
# 2. Bila ada kurung biasa (slug) di akhir: "Title (slug)"
|
||||
if base.endswith(")") and "(" in base:
|
||||
inside = base[base.rfind("(") + 1 : -1].strip()
|
||||
if active_slugs and inside in active_slugs:
|
||||
return inside
|
||||
if inside:
|
||||
return inside
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def parse_roundtrip_csv(
|
||||
content: str | bytes,
|
||||
active_lesson_slugs: list[str] | set[str],
|
||||
|
|
@ -226,14 +260,20 @@ def parse_roundtrip_csv(
|
|||
errors.append(f"Header duplikat: {col!r}")
|
||||
seen_headers.add(col)
|
||||
|
||||
lesson_columns: list[str] = []
|
||||
header_to_slug: dict[str, str] = {}
|
||||
seen_slugs: set[str] = set()
|
||||
for col in fieldnames:
|
||||
if col in REQUIRED_COLUMNS:
|
||||
continue
|
||||
if col not in active:
|
||||
slug = extract_slug_from_header(col, active)
|
||||
if slug not in active:
|
||||
errors.append(f"Kolom lesson tidak dikenal atau tidak aktif: {col!r}")
|
||||
continue
|
||||
lesson_columns.append(col)
|
||||
if slug in seen_slugs:
|
||||
errors.append(f"Header duplikat untuk lesson: {col!r}")
|
||||
continue
|
||||
seen_slugs.add(slug)
|
||||
header_to_slug[col] = slug
|
||||
|
||||
if errors:
|
||||
raise RoundTripImportError(errors)
|
||||
|
|
@ -322,12 +362,12 @@ def parse_roundtrip_csv(
|
|||
|
||||
# progress per lesson
|
||||
progress: dict[str, ParsedProgress] = {}
|
||||
for slug in lesson_columns:
|
||||
value = record.get(slug) or ""
|
||||
for col, slug in header_to_slug.items():
|
||||
value = record.get(col) or ""
|
||||
try:
|
||||
parsed = parse_roundtrip_cell(value)
|
||||
except ValueError as exc:
|
||||
errors.append(f"Baris {line}, kolom {slug}: {exc}")
|
||||
errors.append(f"Baris {line}, kolom {col}: {exc}")
|
||||
continue
|
||||
if parsed is not None:
|
||||
progress[slug] = parsed
|
||||
|
|
@ -350,15 +390,23 @@ def parse_roundtrip_csv(
|
|||
def serialize_export_csv(
|
||||
rows: list[StudentRoundTripRow],
|
||||
lesson_slugs: list[str],
|
||||
lesson_headers: dict[str, str] | None = None,
|
||||
) -> bytes:
|
||||
"""Serialize row export → CSV UTF-8 BOM, delimiter ';', token selalu kosong.
|
||||
|
||||
- Baris pertama: legend grammar sel progress (# Format kolom lesson...).
|
||||
- Header: student_id;token;nama_siswa;<lesson_slugs...> sesuai urutan.
|
||||
- Header: student_id;token;nama_siswa;<lesson_headers...> sesuai urutan.
|
||||
- Status ditulis dengan grammar roundtrip (kosong/completed/<earned>/<total>/done:...).
|
||||
- `completed_count`, role, hash token, dan metadata internal tidak diekspor.
|
||||
"""
|
||||
fieldnames = ["student_id", "token", "nama_siswa", *lesson_slugs]
|
||||
headers_map = lesson_headers or {}
|
||||
resolved_columns: list[tuple[str, str]] = []
|
||||
for item in lesson_slugs:
|
||||
header_label = headers_map.get(item, item)
|
||||
canonical_slug = extract_slug_from_header(item)
|
||||
resolved_columns.append((canonical_slug, header_label))
|
||||
|
||||
fieldnames = ["student_id", "token", "nama_siswa", *[lbl for _, lbl in resolved_columns]]
|
||||
out = io.StringIO()
|
||||
out.write(f"{LEGEND_COMMENT_LINE}\n")
|
||||
writer = csv.DictWriter(
|
||||
|
|
@ -371,7 +419,7 @@ def serialize_export_csv(
|
|||
"token": "", # selalu kosong — tidak ada export/recovery token
|
||||
"nama_siswa": row.display_name,
|
||||
}
|
||||
for slug in lesson_slugs:
|
||||
record[slug] = format_roundtrip_cell(row.progress.get(slug))
|
||||
for slug, col_name in resolved_columns:
|
||||
record[col_name] = format_roundtrip_cell(row.progress.get(slug))
|
||||
writer.writerow(record)
|
||||
return out.getvalue().encode("utf-8-sig")
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ def test_export_csv_content_and_no_leak():
|
|||
assert csv_bytes.startswith("\ufeff".encode("utf-8"))
|
||||
lines = text.strip().splitlines()
|
||||
assert lines[0].startswith("# Format kolom lesson:")
|
||||
assert lines[1] == "student_id;token;nama_siswa;hello_world;variabel"
|
||||
assert lines[1] == "student_id;token;nama_siswa;hello_world [completed];variabel [completed]"
|
||||
assert len(lines) == 3 # hanya 1 siswa (teacher tidak ikut)
|
||||
cols = lines[2].split(";")
|
||||
assert cols[0] == s1.id
|
||||
|
|
@ -612,7 +612,7 @@ def test_roundtrip_export_restore_delete_recreate():
|
|||
text = csv_bytes.decode("utf-8-sig")
|
||||
lines = text.strip().splitlines()
|
||||
assert lines[0].startswith("# Format kolom lesson:")
|
||||
assert lines[1] == "student_id;token;nama_siswa;hello_world;variabel"
|
||||
assert lines[1] == "student_id;token;nama_siswa;hello_world [completed];variabel [completed]"
|
||||
cols = lines[2].split(";")
|
||||
assert cols[0] == s1.id and cols[1] == "" # token kosong
|
||||
assert cols[2] == "Budi Santoso" and cols[3] == "completed" and cols[4] == "3/4"
|
||||
|
|
|
|||
|
|
@ -691,3 +691,98 @@ def test_add_student_empty_name_rejected(client):
|
|||
assert data["success"] is False
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_export_modify_import_progress_sync_roundtrip(client):
|
||||
"""Verifikasi bahwa modifikasi progres di CSV (completed, skor, RESET)
|
||||
tersinkronisasi ke model progres komposit dan terefleksi di /progress-report.json.
|
||||
"""
|
||||
seeded = _seed_teacher_and_students()
|
||||
_login_teacher(client)
|
||||
s1, s2 = seeded["students"]
|
||||
|
||||
# 1) Export CSV awal
|
||||
resp_export = client.post("/students/export-csv", json={"student_ids": [s1.id, s2.id]})
|
||||
assert resp_export.status_code == 200
|
||||
|
||||
# 2) Ubah progres: s1 dapat 'completed' di hello_world, s2 dapat '3/4' di variabel
|
||||
modified_rows = [
|
||||
f"{s1.id};;{s1.display_name};completed;",
|
||||
f"{s2.id};;{s2.display_name};;3/4",
|
||||
]
|
||||
csv_content = _valid_csv(modified_rows)
|
||||
|
||||
# 3) Import CSV kembali
|
||||
resp_import = _upload(client, "/students/import", csv_content)
|
||||
assert resp_import.status_code == 200
|
||||
assert resp_import.get_json()["success"] is True
|
||||
|
||||
# 4) Periksa /progress-report.json
|
||||
resp_report = client.get("/progress-report.json")
|
||||
assert resp_report.status_code == 200
|
||||
assert "no-store" in resp_report.headers.get("Cache-Control", "")
|
||||
data = resp_report.get_json()
|
||||
students = {st["id"]: st for st in data["students"]}
|
||||
|
||||
# s1 hello_world harus punya composite 100 dan status completed
|
||||
st1 = students[s1.id]
|
||||
assert st1["hello_world_composite"] == 100
|
||||
assert st1["hello_world"] == "completed"
|
||||
|
||||
# s2 variabel harus punya quiz_score '3/4' dan composite terhitung
|
||||
st2 = students[s2.id]
|
||||
assert st2["variabel_quiz_score"] == "3/4"
|
||||
assert st2["variabel_composite"] is not None
|
||||
|
||||
# 5) Reset progres s1 via CSV 'RESET'
|
||||
reset_rows = [
|
||||
f"{s1.id};;{s1.display_name};RESET;",
|
||||
f"{s2.id};;{s2.display_name};;3/4",
|
||||
]
|
||||
resp_reset = _upload(client, "/students/import", _valid_csv(reset_rows))
|
||||
assert resp_reset.status_code == 200
|
||||
|
||||
resp_report_after = client.get("/progress-report.json")
|
||||
st1_after = next(s for s in resp_report_after.get_json()["students"] if s["id"] == s1.id)
|
||||
assert st1_after["hello_world_composite"] is None
|
||||
|
||||
|
||||
def test_export_csv_headers_contain_expected_format_guide(client):
|
||||
"""Verifikasi bahwa file CSV yang diekspor memuat expected format di header kolom lesson
|
||||
dan file tersebut dapat dimodifikasi serta diimpor kembali dengan sukses.
|
||||
"""
|
||||
seeded = _seed_teacher_and_students()
|
||||
_login_teacher(client)
|
||||
s1, s2 = seeded["students"]
|
||||
|
||||
resp_export = client.post("/students/export-csv", json={"student_ids": [s1.id, s2.id]})
|
||||
assert resp_export.status_code == 200
|
||||
|
||||
text = resp_export.data.decode("utf-8-sig")
|
||||
lines = text.strip().splitlines()
|
||||
assert lines[0].startswith("# Format kolom lesson:")
|
||||
header_line = lines[1]
|
||||
assert "hello_world [completed]" in header_line
|
||||
assert "variabel [completed]" in header_line
|
||||
|
||||
# Modifikasi langsung baris CSV hasil export dengan format baru
|
||||
# Pertahankan header asli berformat: student_id;token;nama_siswa;hello_world [completed];variabel [completed]
|
||||
modified_csv = (
|
||||
f"{lines[0]}\n"
|
||||
f"{header_line}\n"
|
||||
f"{s1.id};;{s1.display_name};completed;\n"
|
||||
f"{s2.id};;{s2.display_name};;3/4\n"
|
||||
).encode("utf-8-sig")
|
||||
|
||||
resp_import = _upload(client, "/students/import", modified_csv)
|
||||
assert resp_import.status_code == 200
|
||||
assert resp_import.get_json()["success"] is True
|
||||
|
||||
resp_report = client.get("/progress-report.json")
|
||||
assert resp_report.status_code == 200
|
||||
data = resp_report.get_json()
|
||||
students = {st["id"]: st for st in data["students"]}
|
||||
assert students[s1.id]["hello_world_composite"] == 100
|
||||
assert students[s2.id]["variabel_quiz_score"] == "3/4"
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from services.student_roundtrip import (
|
|||
StudentRoundTripRow,
|
||||
is_canonical_uuid,
|
||||
mask_student_id,
|
||||
extract_slug_from_header,
|
||||
parse_roundtrip_csv,
|
||||
serialize_export_csv,
|
||||
)
|
||||
|
|
@ -699,3 +700,53 @@ def test_parse_roundtrip_csv_without_legend_row_backward_compatible():
|
|||
"done", exercise_passed=True, quiz_earned=3, quiz_total=6
|
||||
)
|
||||
|
||||
|
||||
def test_extract_slug_from_header_various_formats():
|
||||
active = {"hello_world", "quiz_test", "c_loop"}
|
||||
assert extract_slug_from_header("hello_world", active) == "hello_world"
|
||||
assert extract_slug_from_header("hello_world [completed]", active) == "hello_world"
|
||||
assert extract_slug_from_header("quiz_test [<earned>/4]", active) == "quiz_test"
|
||||
assert extract_slug_from_header("quiz_test [<earned>/<total>]", active) == "quiz_test"
|
||||
assert extract_slug_from_header("c_loop [done:<1|0>:<earned>/5]", active) == "c_loop"
|
||||
assert extract_slug_from_header("Hello World (hello_world) [completed]", active) == "hello_world"
|
||||
assert extract_slug_from_header("Hello World (hello_world)", active) == "hello_world"
|
||||
assert extract_slug_from_header("unknown_col [completed]", active) == "unknown_col"
|
||||
|
||||
|
||||
def test_export_serializer_with_lesson_headers_mapping():
|
||||
rows = _sample_export_rows()
|
||||
lesson_headers = {
|
||||
"hello_world": "hello_world [completed]",
|
||||
"variabel": "variabel [<earned>/4]",
|
||||
}
|
||||
data = serialize_export_csv(rows, ["hello_world", "variabel"], lesson_headers=lesson_headers)
|
||||
text = data.decode("utf-8-sig")
|
||||
lines = text.strip().splitlines()
|
||||
assert lines[1] == "student_id;token;nama_siswa;hello_world [completed];variabel [<earned>/4]"
|
||||
assert lines[2].startswith(f"{UUID_1};;Nama Siswa 1;completed;3/4")
|
||||
|
||||
|
||||
def test_parse_roundtrip_csv_with_inline_format_headers():
|
||||
content = (
|
||||
f"{LEGEND_COMMENT_LINE}\n"
|
||||
"student_id;token;nama_siswa;hello_world [completed];variabel [<earned>/4]\n"
|
||||
f"{UUID_1};;Budi;completed;3/4\n"
|
||||
)
|
||||
rows = parse_roundtrip_csv(content, ["hello_world", "variabel"])
|
||||
assert len(rows) == 1
|
||||
assert rows[0].student_id == UUID_1
|
||||
assert rows[0].progress["hello_world"] == ParsedProgress("completed")
|
||||
assert rows[0].progress["variabel"] == ParsedProgress("scored", 3, 4)
|
||||
|
||||
|
||||
def test_parse_roundtrip_csv_error_with_inline_format_header_reports_column_name():
|
||||
content = (
|
||||
f"{LEGEND_COMMENT_LINE}\n"
|
||||
"student_id;token;nama_siswa;hello_world [completed]\n"
|
||||
f"{UUID_1};;Budi;invalid_format\n"
|
||||
)
|
||||
with pytest.raises(RoundTripImportError) as exc:
|
||||
parse_roundtrip_csv(content, ["hello_world"])
|
||||
assert "kolom hello_world [completed]" in "; ".join(exc.value.errors)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue