elemes/services/tests/test_student_roundtrip.py

753 lines
28 KiB
Python

"""
Unit test parser/formatter round-trip CSV siswa (murni, tanpa database).
Mengunci:
- schema tunggal export/import (BOM, delimiter ';', kolom token kosong);
- validasi all-or-nothing (duplicate UUID/token, status, ukuran, dst.);
- token TIDAK pernah bocor ke error maupun repr row.
"""
from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
from services.progress_status import (
ParsedProgress,
format_progress_status,
format_roundtrip_cell,
parse_progress_status,
parse_roundtrip_cell,
)
from services.student_roundtrip import (
LEGEND_COMMENT_LINE,
MAX_FILE_BYTES,
MAX_ROWS,
RoundTripImportError,
StudentRoundTripRow,
is_canonical_uuid,
mask_student_id,
extract_slug_from_header,
parse_roundtrip_csv,
serialize_export_csv,
)
FIXTURES = Path(__file__).parent / "fixtures"
ACTIVE = ["hello_world", "variabel"]
UUID_1 = "7eab651c-5eb1-4eb8-8fd2-17fd77aec6df"
UUID_2 = "22222222-2222-4222-8222-222222222222"
# ── helper format status ───────────────────────────────────────────
def test_parse_progress_status_contract():
assert parse_progress_status("") == ("not_started", None, None)
assert parse_progress_status("not_started") == ("not_started", None, None)
assert parse_progress_status("completed") == ("completed", None, None)
assert parse_progress_status("3/4") == ("scored", 3, 4)
assert parse_progress_status("0/10") == ("scored", 0, 10)
def test_parse_progress_status_invalid():
for bad in ["selesai", "4/2", "abc", "1/0", "-1/5", "2/1/3", "a/b"]:
with pytest.raises(ValueError):
parse_progress_status(bad)
def test_format_progress_status_contract():
assert format_progress_status(None) == ""
assert format_progress_status(ParsedProgress("completed")) == "completed"
assert format_progress_status(ParsedProgress("scored", 3, 4)) == "3/4"
assert format_progress_status(("not_started", None, None)) == "not_started"
# ── serializer export ──────────────────────────────────────────────
def _sample_export_rows():
return [
StudentRoundTripRow(
line=0,
student_id=UUID_1,
raw_token="",
display_name="Nama Siswa 1",
progress={
"hello_world": ParsedProgress("completed"),
"variabel": ParsedProgress("scored", 3, 4),
},
),
StudentRoundTripRow(
line=0,
student_id=None,
raw_token="",
display_name="Siswa Baru",
progress={"variabel": ParsedProgress("completed")},
),
]
def test_export_serializer_bom_delimiter_and_header():
data = serialize_export_csv(_sample_export_rows(), ["hello_world", "variabel"])
text = data.decode("utf-8-sig")
assert not text.startswith("\ufeff") # BOM sudah dikonsumsi decode utf-8-sig
assert data.startswith("\ufeff".encode("utf-8")) # raw bytes membawa BOM
lines = text.strip().splitlines()
assert lines[0].startswith("# Format kolom lesson:")
assert lines[1] == "student_id;token;nama_siswa;hello_world;variabel"
assert lines[2].startswith(f"{UUID_1};;Nama Siswa 1;completed;3/4")
# row 2: UUID kosong, token kosong, hello_world blank → ";;Siswa Baru;;completed"
assert lines[3] == ";;Siswa Baru;;completed"
def test_export_always_has_empty_token_column():
data = serialize_export_csv(_sample_export_rows(), ["hello_world"])
text = data.decode("utf-8-sig")
data_lines = [l for l in text.strip().splitlines() if not l.startswith("#")]
for line in data_lines[1:]:
cols = line.split(";")
assert cols[1] == "" # token selalu kosong tanpa pengecualian
def test_export_excludes_internal_fields():
text = serialize_export_csv(_sample_export_rows(), ["hello_world"]).decode("utf-8-sig")
assert "completed_count" not in text
assert "token_hash" not in text
assert "digest" not in text
assert "role" not in text
assert "teacher" not in text
# ── parser: valid ──────────────────────────────────────────────────
def test_parse_valid_fixture():
content = (FIXTURES / "student_roundtrip_valid.csv").read_bytes()
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 2
r1 = rows[0]
assert r1.student_id == UUID_1
assert r1.raw_token == "TOKEN_12345678"
assert r1.display_name == "Nama Siswa 1"
assert r1.progress["hello_world"].state == "completed"
assert r1.progress["variabel"] == ParsedProgress("scored", 3, 4)
r2 = rows[1]
assert r2.student_id is None # UUID kosong = siswa baru
# not_started tidak disimpan → sparse; variabel completed dipertahankan
assert "hello_world" not in r2.progress
assert r2.progress["variabel"].state == "completed"
def test_parse_subset_of_lesson_columns():
content = (
"student_id;token;nama_siswa;hello_world\n"
";TOKEN_12345678;Siswa Baru;completed\n"
)
rows = parse_roundtrip_csv(content, ["hello_world", "variabel"])
assert len(rows) == 1
# lesson yang tidak ada kolomnya → not_started (tidak di-progress)
assert "variabel" not in rows[0].progress
def test_parse_header_only_file_is_valid_empty():
content = "student_id;token;nama_siswa;hello_world\n"
rows = parse_roundtrip_csv(content, ACTIVE)
assert rows == []
def test_parse_ignores_empty_rows():
content = (
"student_id;token;nama_siswa;hello_world\n"
"\n"
";TOKEN_12345678;Siswa Baru;completed\n"
"\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
# ── parser: token ──────────────────────────────────────────────────
def test_parse_rejects_empty_token():
bad = "student_id;token;nama_siswa;hello_world\n;;Nama;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(bad, ACTIVE)
assert any("token wajib diisi" in e for e in exc.value.errors)
def test_parse_rejects_whitespace_and_control_chars_in_token():
cases = [
"student_id;token;nama_siswa;hello_world\n; TOKEN_1234;A;completed\n",
"student_id;token;nama_siswa;hello_world\n;TOKEN_12\n34;A;completed\n",
"student_id;token;nama_siswa;hello_world\n;abc;A;completed\n", # < 8 karakter
]
for content in cases:
with pytest.raises(RoundTripImportError):
parse_roundtrip_csv(content, ACTIVE)
def test_parse_rejects_token_too_long():
long_token = "T" * 129
content = f"student_id;token;nama_siswa;hello_world\n;{long_token};A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("128" in e for e in exc.value.errors)
def test_parse_rejects_duplicate_token_in_file():
content = (
"student_id;token;nama_siswa;hello_world\n"
f";TOKEN_12345678;A;completed\n"
f";TOKEN_12345678;B;completed\n"
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("duplikat" in e.lower() for e in exc.value.errors)
# ── parser: token kosong untuk baris existing (hasil export) ───────
def test_parse_accepts_existing_student_id_with_empty_token():
# Baris hasil export: student_id terisi + token kosong → diterima
# (siswa existing dipulihkan/di-update; token lama dipertahankan).
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1};;Nama Siswa Lama;completed\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
r = rows[0]
assert r.student_id == UUID_1
assert r.raw_token == ""
assert r.display_name == "Nama Siswa Lama"
assert r.progress["hello_world"].state == "completed"
def test_parse_accepts_export_snapshot_with_empty_tokens():
# CSV aktual berisi dua baris student_id + token kosong (hasil export
# PostgreSQL) dapat diparse murni; token kosong tidak dianggap duplikat.
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1};;Nama Siswa Lama;completed\n"
f"{UUID_2};;Siswa Kedua;not_started\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 2
assert [r.student_id for r in rows] == [UUID_1, UUID_2]
assert all(r.raw_token == "" for r in rows)
assert "hello_world" in rows[0].progress
assert "hello_world" not in rows[1].progress # not_started → sparse
def test_parse_accepts_whitespace_only_token_with_student_id():
# Token whitespace-only pada baris existing diperlakukan sebagai token
# kosong yang sah.
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1}; ;Nama Siswa Lama;completed\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].raw_token == ""
def test_parse_rejects_whitespace_around_nonempty_token_even_with_student_id():
# Spasi di sekitar token non-kosong tetap ditolak walau student_id terisi.
secret = "TOKEN_RAHASIA_UNIK_001"
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1}; {secret};Nama Siswa Lama;completed\n"
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("spasi" in e for e in exc.value.errors)
assert secret not in str(exc.value) # token tidak bocor ke pesan error
def test_parse_existing_row_with_empty_token_never_leaks_repr():
row = parse_roundtrip_csv(
f"student_id;token;nama_siswa;hello_world\n{UUID_1};;Nama;completed\n",
ACTIVE,
)[0]
assert row.raw_token == ""
assert "token" not in repr(row).lower() or "raw_token" not in repr(row)
# ── parser: student_id / UUID ──────────────────────────────────────
def test_is_canonical_uuid():
assert is_canonical_uuid(UUID_1)
assert not is_canonical_uuid("")
assert not is_canonical_uuid("bukan-uuid")
assert not is_canonical_uuid(UUID_1.upper()) # huruf besar tidak canonical
def test_parse_rejects_invalid_and_duplicate_uuid():
bad_uuid = "student_id;token;nama_siswa;hello_world\nNOT_A_UUID;TOKEN_12345678;A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(bad_uuid, ACTIVE)
assert any("UUID tidak valid" in e for e in exc.value.errors)
dup = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1};TOKEN_12345678;A;completed\n"
f"{UUID_1};TOKEN_87654321;B;completed\n"
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(dup, ACTIVE)
assert any("UUID duplikat" in e for e in exc.value.errors)
def test_parse_invalid_uuid_with_empty_token_reports_only_uuid_error():
# UUID invalid + token kosong: error token "untuk siswa baru" TIDAK boleh
# muncul (baris ini jelas bukan siswa baru — error UUID-nya sudah cukup).
content = "student_id;token;nama_siswa;hello_world\nNOT_A_UUID;;A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("UUID tidak valid" in e for e in exc.value.errors)
assert not any("token wajib diisi" in e for e in exc.value.errors)
# ── parser: header & format file ───────────────────────────────────
def test_parse_rejects_missing_required_column():
content = "token;nama_siswa;hello_world\nTOKEN_12345678;A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("student_id" in e for e in exc.value.errors)
def test_parse_rejects_duplicate_header():
content = "student_id;token;token;nama_siswa;hello_world\n;;T;A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("Header duplikat" in e for e in exc.value.errors)
def test_parse_rejects_unknown_lesson_column():
content = "student_id;token;nama_siswa;hello_world;tidak_ada\n;TOKEN_12345678;A;completed;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("tidak_ada" in e for e in exc.value.errors)
def test_parse_rejects_inactive_lesson_column():
# lesson tidak ada di daftar aktif → ditolak (bukan diam-diam di-ignore)
content = "student_id;token;nama_siswa;hello_world\n;TOKEN_12345678;A;completed\n"
with pytest.raises(RoundTripImportError):
parse_roundtrip_csv(content, ["variabel"])
def test_parse_rejects_unsupported_tab_delimiter():
# Delimiter tab tidak didukung; seluruh header menjadi satu field pada
# semua kandidat → error delimiter yang jelas, BUKAN seluruh header
# ditampilkan sebagai satu unknown lesson.
content = "student_id\ttoken\tnama_siswa\thello_world\n\tTOKEN_12345678\tA\tcompleted\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
joined = "; ".join(exc.value.errors)
assert "titik koma (;)" in joined or "koma (,)" in joined
assert "Kolom lesson tidak dikenal" not in joined
# ── parser: delimiter koma (`,`) ───────────────────────────────────
def test_parse_accepts_comma_delimiter_for_new_student():
# Reproduksi file lampiran pengguna: header koma + row siswa baru.
content = (
"student_id,token,nama_siswa,hello_world,variabel\r\n"
",LocustBot48,Locust Bot 48,completed,\r\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].student_id is None
assert rows[0].raw_token == "LocustBot48"
assert rows[0].display_name == "Locust Bot 48"
assert rows[0].progress["hello_world"].state == "completed"
def test_parse_accepts_comma_delimiter_with_bom():
content = (
"\ufeffstudent_id,token,nama_siswa,hello_world\r\n"
",TOKEN_12345678,Siswa Baru,completed\r\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].raw_token == "TOKEN_12345678"
assert rows[0].display_name == "Siswa Baru"
def test_parse_accepts_comma_delimiter_existing_student_empty_token():
# Baris hasil export (student_id terisi + token kosong) juga valid
# dengan delimiter koma.
content = (
"student_id,token,nama_siswa,hello_world\r\n"
f"{UUID_1},,Nama Siswa Lama,completed\r\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].student_id == UUID_1
assert rows[0].raw_token == ""
def test_parse_comma_csv_rejects_duplicate_header():
content = "student_id,token,token,nama_siswa,hello_world\n,,T,A,completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("Header duplikat" in e for e in exc.value.errors)
def test_parse_comma_csv_rejects_unknown_lesson_column():
content = "student_id,token,nama_siswa,hello_world,tidak_ada\n,TOKEN_12345678,A,completed,completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("tidak_ada" in e for e in exc.value.errors)
def test_parse_comma_csv_errors_never_contain_token_value():
secret = "TOKEN_RAHASIA_UNIK_001"
content = (
"student_id,token,nama_siswa,hello_world\n"
f",{secret},A,completed\n"
f",{secret},B,completed\n" # duplikat → error yang menyebut token
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert secret not in str(exc.value)
for error in exc.value.errors:
assert secret not in error
# ── parser: quoted field (delimiter di dalam nilai, bukan di-split naif) ──
def test_parse_quoted_comma_in_name_comma_csv():
# Koma di dalam quoted field harus tetap satu nilai, bukan split manual.
content = (
"student_id,token,nama_siswa,hello_world\r\n"
',TOKEN_12345678,"Doe, Budi",completed\r\n'
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].display_name == "Doe, Budi"
def test_parse_quoted_semicolon_in_name_semicolon_csv():
# Titik koma di dalam quoted field harus tetap satu nilai pada
# delimiter canonical.
content = (
"student_id;token;nama_siswa;hello_world\r\n"
';TOKEN_12345678;"Budi; Santoso";completed\r\n'
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].display_name == "Budi; Santoso"
def test_parse_mixed_delimiter_reports_schema_errors_not_whole_header():
# Header campuran ; dan , terpecah menjadi beberapa field tapi tidak
# memenuhi schema pada delimiter manapun → error kolom wajib yang
# jelas, dan seluruh raw header TIDAK tampil sebagai satu unknown
# lesson (yang menyesatkan).
content = "student_id;token,nama_siswa;hello_world\n;TOKEN_12345678,A,completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
joined = "; ".join(exc.value.errors)
assert "Kolom wajib hilang" in joined
assert "student_id;token,nama_siswa;hello_world" not in joined
def test_parse_rejects_csv_layer_error():
# Field melebihi batas field csv (131072) → csv.Error saat parsing
content = (
"student_id;token;nama_siswa;hello_world\n"
";TOKEN_12345678;" + "x" * 200000 + ";completed\n"
)
with pytest.raises(RoundTripImportError):
parse_roundtrip_csv(content, ACTIVE)
def test_parse_rejects_empty_file():
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv("", ACTIVE)
assert any("kosong" in e or "header" in e.lower() for e in exc.value.errors)
def test_parse_rejects_invalid_progress_status():
content = "student_id;token;nama_siswa;hello_world;variabel\n;TOKEN_12345678;A;selesai;4/2\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
joined = "; ".join(exc.value.errors)
assert "selesai" in joined
assert "4/2" in joined
def test_parse_rejects_name_too_long():
name = "N" * 256
content = f"student_id;token;nama_siswa;hello_world\n;TOKEN_12345678;{name};completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("255" in e for e in exc.value.errors)
def test_parse_rejects_too_many_rows():
header = "student_id;token;nama_siswa;hello_world\n"
body = "".join(f";TOKEN_{i:08d};Siswa {i};completed\n" for i in range(MAX_ROWS + 1))
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(header + body, ACTIVE)
assert any(f"batas {MAX_ROWS}" in e for e in exc.value.errors)
def test_parse_rejects_file_too_big():
big = b"a" * (MAX_FILE_BYTES + 1)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(big, ACTIVE)
assert any("5 MiB" in e for e in exc.value.errors)
# ── keamanan: token tidak bocor ────────────────────────────────────
def test_errors_never_contain_token_value():
secret = "TOKEN_RAHASIA_UNIK_001"
content = (
"student_id;token;nama_siswa;hello_world\n"
f";{secret};A;completed\n"
f";{secret};B;completed\n" # duplikat → error yang menyebut token
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert secret not in str(exc.value)
for error in exc.value.errors:
assert secret not in error
def test_row_repr_never_contains_token():
row = StudentRoundTripRow(
line=2,
student_id=UUID_1,
raw_token="TOKEN_RAHASIA_UNIK_001",
display_name="Budi",
progress={},
)
assert "TOKEN_RAHASIA_UNIK_001" not in repr(row)
def test_mask_student_id():
assert mask_student_id(UUID_1) == "7eab651c…"
assert mask_student_id(None) == ""
assert mask_student_id("") == ""
# ── grammar sel roundtrip (done, reset, format, parse) ───────────────
def test_parse_roundtrip_cell_valid():
assert parse_roundtrip_cell("") is None
assert parse_roundtrip_cell("not_started") is None
assert parse_roundtrip_cell("completed") == ParsedProgress(state="completed")
assert parse_roundtrip_cell("3/4") == ParsedProgress(state="scored", score_earned=3, score_total=4)
assert parse_roundtrip_cell("0/10") == ParsedProgress(state="scored", score_earned=0, score_total=10)
# RESET case-insensitive
assert parse_roundtrip_cell("RESET") == ParsedProgress(state="reset")
assert parse_roundtrip_cell("reset") == ParsedProgress(state="reset")
assert parse_roundtrip_cell("Reset") == ParsedProgress(state="reset")
# done dengan berbagai variasi
assert parse_roundtrip_cell("done:1:3/6") == ParsedProgress(
state="done", exercise_passed=True, quiz_earned=3, quiz_total=6
)
assert parse_roundtrip_cell("done:0:0/4") == ParsedProgress(
state="done", exercise_passed=False, quiz_earned=0, quiz_total=4
)
assert parse_roundtrip_cell("done:1:") == ParsedProgress(
state="done", exercise_passed=True, quiz_earned=None, quiz_total=None
)
assert parse_roundtrip_cell("done:0:") == ParsedProgress(
state="done", exercise_passed=False, quiz_earned=None, quiz_total=None
)
assert parse_roundtrip_cell("done::4/4") == ParsedProgress(
state="done", exercise_passed=None, quiz_earned=4, quiz_total=4
)
assert parse_roundtrip_cell("done::") == ParsedProgress(
state="done", exercise_passed=None, quiz_earned=None, quiz_total=None
)
def test_parse_roundtrip_cell_invalid():
invalid_cases = [
"done:2:3/6", # ex bukan 1/0/kosong
"done:1:3/6/7", # quiz format salah
"done:1:5/3", # earned > total
"done:1:-1/3", # negative earned
"done:1:3/0", # total 0
"done:1:abc", # non-numeric quiz
"done:1", # kurang separator
"done:1:2:3", # kelebihan separator
"done", # bukan done:<ex>:<quiz>
"in_progress", # bukan grammar roundtrip
"selesai",
"4/2", # earned > total di scored biasa
"-1/5",
"1/0",
"abc",
]
for bad in invalid_cases:
with pytest.raises(ValueError):
parse_roundtrip_cell(bad)
def test_format_roundtrip_cell():
assert format_roundtrip_cell(None) == ""
assert format_roundtrip_cell(ParsedProgress("completed")) == "completed"
assert format_roundtrip_cell(ParsedProgress("scored", score_earned=3, score_total=4)) == "3/4"
assert format_roundtrip_cell(
ParsedProgress("done", exercise_passed=True, quiz_earned=3, quiz_total=6)
) == "done:1:3/6"
assert format_roundtrip_cell(
ParsedProgress("done", exercise_passed=False, quiz_earned=0, quiz_total=4)
) == "done:0:0/4"
assert format_roundtrip_cell(
ParsedProgress("done", exercise_passed=True)
) == "done:1:"
assert format_roundtrip_cell(
ParsedProgress("done", exercise_passed=False)
) == "done:0:"
assert format_roundtrip_cell(
ParsedProgress("done", quiz_earned=4, quiz_total=4)
) == "done::4/4"
assert format_roundtrip_cell(
ParsedProgress("done")
) == "done::"
assert format_roundtrip_cell(ParsedProgress("not_started")) == ""
def test_export_preserves_done_state_in_csv():
rows = [
StudentRoundTripRow(
line=0,
student_id=UUID_1,
raw_token="",
display_name="Budi",
progress={
"hello_world": ParsedProgress(
"done", exercise_passed=True, quiz_earned=3, quiz_total=6
),
"variabel": ParsedProgress(
"done", exercise_passed=True, quiz_earned=None, quiz_total=None
),
},
)
]
data = serialize_export_csv(rows, ["hello_world", "variabel"])
text = data.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[2] == f"{UUID_1};;Budi;done:1:3/6;done:1:"
def test_parse_roundtrip_csv_with_legend_row_and_offset():
content = (
f"{LEGEND_COMMENT_LINE}\n"
"student_id;token;nama_siswa;hello_world;variabel\n"
f"{UUID_1};;Budi;done:1:3/6;RESET\n"
";TOKEN_12345678;Siswa Baru;completed;done::4/4\n"
)
rows = parse_roundtrip_csv(content, ["hello_world", "variabel"])
assert len(rows) == 2
assert rows[0].line == 3 # physical line 3 in file (legend=1, header=2, row1=3)
assert rows[0].progress["hello_world"] == ParsedProgress(
"done", exercise_passed=True, quiz_earned=3, quiz_total=6
)
assert rows[0].progress["variabel"] == ParsedProgress("reset")
assert rows[1].line == 4
assert rows[1].progress["hello_world"] == ParsedProgress("completed")
assert rows[1].progress["variabel"] == ParsedProgress(
"done", exercise_passed=None, quiz_earned=4, quiz_total=4
)
def test_parse_roundtrip_csv_error_reports_correct_physical_line_with_comment():
content = (
"# Baris komentar 1\n"
"# Baris komentar 2\n"
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1};;Budi;done:2:3/6\n" # physical line 4, invalid exercise value
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ["hello_world"])
assert "Baris 4, kolom hello_world" in "; ".join(exc.value.errors)
def test_parse_roundtrip_csv_without_legend_row_backward_compatible():
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1};;Budi;done:1:3/6\n"
)
rows = parse_roundtrip_csv(content, ["hello_world"])
assert len(rows) == 1
assert rows[0].line == 2
assert rows[0].progress["hello_world"] == ParsedProgress(
"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)