""" 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:: "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 [/4]", active) == "quiz_test" assert extract_slug_from_header("quiz_test [/]", active) == "quiz_test" assert extract_slug_from_header("c_loop [done:<1|0>:/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 [/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 [/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 [/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)