From ac10692b611c9a267ceba99047d1da5c904cbb3f Mon Sep 17 00:00:00 2001 From: a2nr Date: Mon, 17 Aug 2026 21:57:10 +0700 Subject: [PATCH] feat(prereq): dukung ambang minimum (min: N%) pada prasyarat materi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tambahkan logika ambang minimum skor (opsional) di daftar prasyarat. Tanpa ambang, status scored berapa pun (termasuk 0/x) memenuhi syarat (backward compatible). Dengan ambang `min: N%`, earned/total harus melebihi ambang untuk membuka kunci materi berikutnya. Perubahan: - Parser bullet prasyarat mendukung suffix `min: N%` (contoh: min: 75%) - is_prerequisite_met: completed → selalu terpenuhi; scored → terpenuhi kecuali di bawah ambang; not_started → belum terpenuhi - routes/lessons.py: 3 titik kunci sekarang pakai get_missing_prerequisites - latex_test.md: tambahkan prereq quiz_test min: 75% (demo nyata) - examples/content disinkronkan dengan content - Tambah test_prerequisite_logic.py (28 unit test) + 3 integration test --- docs/13-content-sub-home.md | 15 ++ examples/content/dasar/latex_test.md | 3 + examples/content/dasar/quiz_test.md | 2 +- examples/content/home.md | 4 - routes/lessons.py | 44 +++--- services/lesson_service.py | 159 +++++++++++++++++----- services/tests/test_prerequisite_logic.py | 149 ++++++++++++++++++++ services/tests/test_sub_home_api.py | 94 +++++++++++++ 8 files changed, 408 insertions(+), 62 deletions(-) create mode 100644 services/tests/test_prerequisite_logic.py diff --git a/docs/13-content-sub-home.md b/docs/13-content-sub-home.md index 7714abc..1ea5b6e 100644 --- a/docs/13-content-sub-home.md +++ b/docs/13-content-sub-home.md @@ -88,6 +88,21 @@ Mengembalikan JSON isi `sub-home.md`: Respons `404` bila folder tidak ada atau tidak memiliki `sub-home.md`. +**Prasyarat materi (`**Prerequisites:**` di dalam `---LESSON_INFO---`):** +Setiap bullet bisa berupa link `[Judul](lesson/slug.md)` atau teks biasa. Status +siswa `completed` selalu memenuhi prasyarat; status skor (mis. `3/4`) juga +memenuhi untuk skor berapa pun. Ambang minimum opsional ditulis sebagai suffix +`min: N%` pada bullet: + +```markdown +**Prerequisites:** +- [Kuis Dasar](lesson/quiz_dasar.md) +- [Ujian Tengah](lesson/uts.md) min: 75% +``` + +Bila `min: N%` dicantumkan, prasyarat baru terpenuhi bila `earned/total >= N%` +(mis. `3/4` memenuhi `min: 75%`, `2/4` tidak). + ### `GET /lesson/.json` Bila lesson berada di folder yang memiliki `sub-home.md`, field `ordered_lessons` diff --git a/examples/content/dasar/latex_test.md b/examples/content/dasar/latex_test.md index 31806ce..828f732 100644 --- a/examples/content/dasar/latex_test.md +++ b/examples/content/dasar/latex_test.md @@ -2,6 +2,9 @@ **Learning Objectives:** - Memahami cara penulisan LaTeX di LMS. - Verifikasi perenderan rumus matematika. + +**Prerequisites:** +- [Uji Coba Kuis](lesson/quiz_test.md) min: 75% ---END_LESSON_INFO--- # Uji Coba LaTeX diff --git a/examples/content/dasar/quiz_test.md b/examples/content/dasar/quiz_test.md index 1d20f41..8dc3527 100644 --- a/examples/content/dasar/quiz_test.md +++ b/examples/content/dasar/quiz_test.md @@ -9,7 +9,7 @@ image: https://upload.wikimedia.org/wikipedia/commons/1/18/C_Programming_Languag - [x] Salah > Penjelasan: Bahasa C sebenarnya dikategorikan sebagai bahasa tingkat menengah (*middle-level language*) karena memiliki fitur bahasa tingkat rendah (seperti manipulasi memori) sekaligus fitur bahasa tingkat tinggi (seperti struktur kontrol yang manusiawi). -### Apa output dari `printf("%d", 5 + 5);`? +### Apa output dari `printf("%d", 5 + 5);` ::diagnostic - [] 55 - [x] 10 diff --git a/examples/content/home.md b/examples/content/home.md index 870495c..2868c52 100644 --- a/examples/content/home.md +++ b/examples/content/home.md @@ -18,10 +18,6 @@ Semua materi bisa dikerjakan langsung di browser! [Hello, World!](lesson/hello_world.md) [Variabel](lesson/variabel.md) -[Uji Coba Flowchart](lesson/flowchart_test.md) -[Uji Coba Kuis](lesson/quiz_test.md) -[Uji Coba LaTeX](lesson/latex_test.md) -[Uji Coba Slide](lesson/test_slides.md) [Rangkaian Dasar](lesson/rangkaian_dasar.md) [LED Blink](lesson/led_blink_arduino.md) [Hello Serial](lesson/hello_serial_arduino.md) diff --git a/routes/lessons.py b/routes/lessons.py index 0972b80..d19dd72 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -10,6 +10,7 @@ from werkzeug.utils import secure_filename from compiler import compiler_factory from config import ASSETS_DIR from services.lesson_service import ( + get_missing_prerequisites, get_ordered_lessons_with_learning_objectives, get_sub_home_data, get_sub_home_path, @@ -23,6 +24,15 @@ from services.token_service import get_student_progress lessons_bp = Blueprint('lessons', __name__) +def _prereq_specs(lesson): + """Spec prasyarat untuk pengecekan kunci. + + Prioritas `prerequisite_specs` (slug + min_percent); fallback ke + `prerequisites` (slug saja) bila spec belum ada (data legacy). + """ + return lesson.get('prerequisite_specs') or lesson.get('prerequisites', []) + + @lessons_bp.route('/lessons') def api_lessons(): """Return lesson list + home content as JSON.""" @@ -33,17 +43,11 @@ def api_lessons(): progress = get_student_progress(token) lessons = get_ordered_lessons_with_learning_objectives(progress) - - # Calculate locked status + + # Calculate locked status — prasyarat terpenuhi bila completed ATAU sudah + # discor (skor berapa pun, atau di atas ambang `min: N%` bila dideklarasikan). for lesson in lessons: - prereqs = lesson.get('prerequisites', []) - is_locked = False - if prereqs: - for p_slug in prereqs: - if not progress or progress.get(p_slug) != 'completed': - is_locked = True - break - lesson['locked'] = is_locked + lesson['locked'] = bool(get_missing_prerequisites(progress, _prereq_specs(lesson))) home_content = render_home_content() @@ -73,14 +77,7 @@ def api_bab(folder): source_path=source_path if source_path else None, ) for lesson in ordered: - prereqs = lesson.get('prerequisites', []) - is_locked = False - if prereqs: - for p_slug in prereqs: - if not progress or progress.get(p_slug) != 'completed': - is_locked = True - break - lesson['locked'] = is_locked + lesson['locked'] = bool(get_missing_prerequisites(progress, _prereq_specs(lesson))) data['lessons'] = ordered return jsonify(data) @@ -188,15 +185,12 @@ def api_lesson(filename): current_lesson_meta = les break - # Prerequisite check - is_locked = False + # Prerequisite check — status scored (mis. "3/4") juga memenuhi prasyarat, + # kecuali ada ambang `min: N%` yang belum tercapai. missing_prereqs = [] if current_lesson_meta: - prereqs = current_lesson_meta.get('prerequisites', []) - for p_slug in prereqs: - if not progress or progress.get(p_slug) != 'completed': - is_locked = True - missing_prereqs.append(p_slug) + missing_prereqs = get_missing_prerequisites(progress, _prereq_specs(current_lesson_meta)) + is_locked = bool(missing_prereqs) prev_lesson = all_lessons[current_idx - 1] if current_idx > 0 else None next_lesson = all_lessons[current_idx + 1] if 0 <= current_idx < len(all_lessons) - 1 else None diff --git a/services/lesson_service.py b/services/lesson_service.py index 8b50b83..1ace5f9 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -13,6 +13,7 @@ from urllib.parse import urlparse import markdown as md from config import CONTENT_DIR +from services.progress_status import parse_progress_status # Generic file cache (path -> {content, mtime}) _file_cache = {} # {path: {'content': str, 'mtime': float}} @@ -189,6 +190,102 @@ def get_lesson_names(): return names +# --------------------------------------------------------------------------- +# Prerequisite helpers +# --------------------------------------------------------------------------- + +_MIN_PERCENT_RE = re.compile(r'\bmin(?:imum)?\s*:?\s*(\d+(?:[.,]\d+)?)\s*%') + + +def _parse_prerequisite_bullet(bullet): + """Parse satu bullet prasyarat → (slug_or_title, min_percent) | None. + + Mendukung format: + - [Kuis](lesson/quiz.md) → ("quiz", None) + - [Kuis](lesson/quiz.md) min: 75% → ("quiz", 75.0) + - Kuis min 75% → ("Kuis", 75.0) + - "Tidak ada" / "None" / "-" → None (difilter) + + `min: N%` bersifat opsional — ambang minimum skor (persen). Tanpa ambang, + skor berapa pun (termasuk 0/x) dianggap memenuhi prasyarat. + """ + bullet = bullet.strip() + # Terima juga bentuk dengan dash awal (mis. hasil split manual) — di + # produksi bullet biasanya sudah dipotong dash oleh regex `- ([^\n]+)`. + if bullet.startswith('-'): + bullet = bullet[1:].strip() + if bullet.lower() in ('tidak ada', 'none', '-', ''): + return None + + min_percent = None + min_match = _MIN_PERCENT_RE.search(bullet) + if min_match: + try: + min_percent = float(min_match.group(1).replace(',', '.')) + except ValueError: + min_percent = None + bullet = bullet[:min_match.start()].strip().rstrip(',').strip() + + # Check if it's a markdown link format [title](path) + md_link_match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', bullet) + if md_link_match: + # Extract slug from the link path + link_path = md_link_match.group(2) + # Handle paths like lesson/hello_world.md or just hello_world.md + slug = link_path.replace('.md', '').split('/')[-1] + return slug, min_percent + # Plain text - keep as title for later resolution + return bullet, min_percent + + +def is_prerequisite_met(progress, prereq) -> bool: + """True bila prasyarat terpenuhi berdasarkan status progress siswa. + + `prereq` berupa slug string, atau dict {'slug': ..., 'min_percent': ...}. + + - progress kosong / status not_started / status tidak dikenal → belum terpenuhi. + - "completed" → terpenuhi (skor & ambang diabaikan). + - skor "/" (mis. "3/4") → terpenuhi untuk skor berapa pun, + KECUALI ada `min_percent` (mis. 75.0): wajib earned/total >= min_percent. + """ + if isinstance(prereq, str): + slug = prereq + min_percent = None + else: + slug = prereq['slug'] + min_percent = prereq.get('min_percent') + + if not progress: + return False + try: + state, earned, total = parse_progress_status(progress.get(slug, '')) + except (ValueError, TypeError): + return False + if state == 'not_started': + return False + if state == 'completed': + return True + # state == 'scored' + if min_percent is None: + return True + if earned is None or not total: + return False + return (earned / total) * 100 >= min_percent + + +def get_missing_prerequisites(progress, prereqs): + """Slug prasyarat yang belum terpenuhi, urut sesuai deklarasi materi. + + Menerima daftar slug string ATAU dict {'slug', 'min_percent'} — kompatibel + dengan `lesson['prerequisites']` (legacy) dan `lesson['prerequisite_specs']`. + """ + missing = [] + for prereq in prereqs or []: + if not is_prerequisite_met(progress, prereq): + missing.append(prereq['slug'] if isinstance(prereq, dict) else prereq) + return missing + + def _build_lessons_with_objectives(lesson_links): """Build enriched lesson dicts (title, description, prerequisite_titles) from parsed (link_text, filename) pairs, reading each lesson file.""" @@ -208,6 +305,7 @@ def _build_lessons_with_objectives(lesson_links): lesson_info_start = content.find('---LESSON_INFO---') lesson_info_end = content.find('---END_LESSON_INFO---') prerequisite_titles = [] + prerequisite_specs = [] if lesson_info_start != -1 and lesson_info_end != -1: lesson_info_section = content[lesson_info_start + len('---LESSON_INFO---'):lesson_info_end] @@ -227,30 +325,18 @@ def _build_lessons_with_objectives(lesson_links): prereq_start = lesson_info_section.find('**Prerequisites:**') if prereq_start != -1: prereq_section = lesson_info_section[prereq_start + len('**Prerequisites:**'):] - # Look for bullet points - support both plain text and markdown link format - # Plain text: - Hello, World! - # Markdown link: - [Hello, World!](lesson/hello_world.md) bullet_lines = re.findall(r'- ([^\n]+)', prereq_section) - + prerequisite_slugs = [] + prerequisite_specs = [] for bullet in bullet_lines: - bullet = bullet.strip() - # Filter out "None" or "Tidak ada" - if bullet.lower() in ('tidak ada', 'none', '-', ''): + parsed = _parse_prerequisite_bullet(bullet) + if parsed is None: continue - - # Check if it's a markdown link format [title](path) - md_link_match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', bullet) - if md_link_match: - # Extract slug from the link path - link_path = md_link_match.group(2) - # Handle paths like lesson/hello_world.md or just hello_world.md - slug = link_path.replace('.md', '').split('/')[-1] - prerequisite_slugs.append(slug) - else: - # Plain text - keep as title for later resolution - prerequisite_slugs.append(bullet) - + slug_or_title, min_percent = parsed + prerequisite_slugs.append(slug_or_title) + prerequisite_specs.append({'slug': slug_or_title, 'min_percent': min_percent}) + prerequisite_titles = prerequisite_slugs content_after_info = content[lesson_info_end + len('---END_LESSON_INFO---'):].strip() @@ -272,6 +358,7 @@ def _build_lessons_with_objectives(lesson_links): 'description': description, 'path': file_path, 'prerequisite_titles': prerequisite_titles, + 'prerequisite_specs': prerequisite_specs, }) return lessons @@ -318,15 +405,25 @@ def get_ordered_lessons_with_learning_objectives(progress=None, source_path=None # Resolve prerequisites - now contains slugs directly from markdown links # or still contains plain text titles that need resolution items = lesson.get('prerequisite_titles', []) + specs = lesson.get('prerequisite_specs') or [ + {'slug': item, 'min_percent': None} for item in items + ] resolved_prereqs = [] - for item in items: + resolved_specs = [] + for i, item in enumerate(items): # If it's already a valid slug (exists in all_lessons), use it directly if item in title_to_slug.values(): - resolved_prereqs.append(item) + slug = item # Otherwise try to resolve via title mapping elif item in title_to_slug: - resolved_prereqs.append(title_to_slug[item]) + slug = title_to_slug[item] + else: + continue + resolved_prereqs.append(slug) + spec = specs[i] if i < len(specs) else {} + resolved_specs.append({'slug': slug, 'min_percent': spec.get('min_percent')}) lesson['prerequisites'] = resolved_prereqs + lesson['prerequisite_specs'] = resolved_specs return lesson if lesson_links: @@ -457,6 +554,7 @@ def get_sub_home_data(folder_name): lesson_title = link_text description = "Learn C programming concepts with practical examples." prerequisite_titles = [] + prerequisite_specs = [] lesson_info_start = lesson_content.find('---LESSON_INFO---') lesson_info_end = lesson_content.find('---END_LESSON_INFO---') if lesson_info_start != -1 and lesson_info_end != -1: @@ -473,16 +571,12 @@ def get_sub_home_data(folder_name): prereq_section = lesson_info_section[prereq_start + len('**Prerequisites:**'):] bullet_lines = re.findall(r'- ([^\n]+)', prereq_section) for bullet in bullet_lines: - bullet = bullet.strip() - if bullet.lower() in ('tidak ada', 'none', '-', ''): + parsed = _parse_prerequisite_bullet(bullet) + if parsed is None: continue - md_link_match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', bullet) - if md_link_match: - link_path = md_link_match.group(2) - slug = link_path.replace('.md', '').split('/')[-1] - prerequisite_titles.append(slug) - else: - prerequisite_titles.append(bullet) + slug_or_title, min_percent = parsed + prerequisite_titles.append(slug_or_title) + prerequisite_specs.append({'slug': slug_or_title, 'min_percent': min_percent}) else: for line in lesson_content.split('\n')[:10]: if line.startswith('# '): @@ -495,6 +589,7 @@ def get_sub_home_data(folder_name): 'description': description, 'path': file_path, 'prerequisite_titles': prerequisite_titles, + 'prerequisite_specs': prerequisite_specs, }) data = { diff --git a/services/tests/test_prerequisite_logic.py b/services/tests/test_prerequisite_logic.py new file mode 100644 index 0000000..e95ced9 --- /dev/null +++ b/services/tests/test_prerequisite_logic.py @@ -0,0 +1,149 @@ +"""Unit test logika prasyarat: status skor & ambang minimum (`min: N%`). + +Kontrak yang diuji: +- Status `scored` (mis. "3/4") memenuhi prasyarat untuk skor berapa pun. +- `completed` selalu memenuhi. +- `not_started` / progress kosong / status tidak dikenal → belum terpenuhi. +- Bila ada ambang `min_percent`, skor wajib earned/total >= ambang. +""" + +import pytest + +pytestmark = pytest.mark.unit + +from services.lesson_service import ( + _parse_prerequisite_bullet, + get_missing_prerequisites, + is_prerequisite_met, +) + + +# --------------------------------------------------------------------------- +# _parse_prerequisite_bullet +# --------------------------------------------------------------------------- + +def test_parse_bullet_link_without_threshold(): + assert _parse_prerequisite_bullet("- [Kuis](lesson/quiz.md)") == ("quiz", None) + + +def test_parse_bullet_link_with_percent_threshold(): + assert _parse_prerequisite_bullet("- [Kuis](lesson/quiz.md) min: 75%") == ("quiz", 75.0) + + +def test_parse_bullet_link_without_colon(): + assert _parse_prerequisite_bullet("- [Kuis](lesson/quiz.md) min 50%") == ("quiz", 50.0) + + +def test_parse_bullet_link_with_minimum_word(): + assert _parse_prerequisite_bullet("- [Kuis](lesson/quiz.md) minimum: 80%") == ("quiz", 80.0) + + +def test_parse_bullet_link_with_comma_decimal(): + assert _parse_prerequisite_bullet("- [Kuis](lesson/quiz.md) min: 62,5%") == ("quiz", 62.5) + + +def test_parse_bullet_plain_text_with_threshold(): + assert _parse_prerequisite_bullet("- Kuis min 75%") == ("Kuis", 75.0) + + +def test_parse_bullet_plain_text_without_threshold(): + assert _parse_prerequisite_bullet("- Dasar") == ("Dasar", None) + + +def test_parse_bullet_trailing_comma_before_threshold(): + assert _parse_prerequisite_bullet("- [Kuis](lesson/quiz.md), min: 75%") == ("quiz", 75.0) + + +@pytest.mark.parametrize("bullet", ["- Tidak ada", "- None", "-", ""]) +def test_parse_bullet_skip_values(bullet): + assert _parse_prerequisite_bullet(bullet) is None + + +def test_parse_bullet_path_with_folder(): + assert _parse_prerequisite_bullet("- [Dasar](lesson/dasar.md)") == ("dasar", None) + + +# --------------------------------------------------------------------------- +# is_prerequisite_met +# --------------------------------------------------------------------------- + +def test_met_no_progress(): + assert is_prerequisite_met(None, "dasar") is False + assert is_prerequisite_met({}, "dasar") is False + + +def test_met_not_started(): + assert is_prerequisite_met({"dasar": ""}, "dasar") is False + assert is_prerequisite_met({"dasar": "not_started"}, "dasar") is False + + +def test_met_completed(): + assert is_prerequisite_met({"dasar": "completed"}, "dasar") is True + + +def test_met_any_score_satisfies(): + assert is_prerequisite_met({"quiz": "3/4"}, "quiz") is True + assert is_prerequisite_met({"quiz": "0/4"}, "quiz") is True + assert is_prerequisite_met({"quiz": "4/4"}, "quiz") is True + + +def test_met_unknown_status(): + assert is_prerequisite_met({"dasar": "xyz"}, "dasar") is False + + +def test_met_missing_slug(): + assert is_prerequisite_met({"lain": "completed"}, "dasar") is False + + +def test_met_spec_dict_without_threshold(): + assert is_prerequisite_met({"quiz": "2/4"}, {"slug": "quiz", "min_percent": None}) is True + + +def test_met_threshold_below(): + spec = {"slug": "quiz", "min_percent": 75.0} + assert is_prerequisite_met({"quiz": "2/4"}, spec) is False + assert is_prerequisite_met({"quiz": "3/4"}, spec) is True + + +def test_met_threshold_exact_boundary(): + spec = {"slug": "quiz", "min_percent": 62.5} + assert is_prerequisite_met({"quiz": "4/8"}, spec) is False + assert is_prerequisite_met({"quiz": "5/8"}, spec) is True + + +def test_met_completed_ignores_threshold(): + spec = {"slug": "quiz", "min_percent": 90.0} + assert is_prerequisite_met({"quiz": "completed"}, spec) is True + + +def test_met_threshold_with_zero_total(): + spec = {"slug": "quiz", "min_percent": 75.0} + # total 0 tidak valid → tidak terpenuhi + assert is_prerequisite_met({"quiz": "0/0"}, spec) is False + + +# --------------------------------------------------------------------------- +# get_missing_prerequisites +# --------------------------------------------------------------------------- + +def test_missing_returns_only_unmet_in_order(): + progress = {"a": "completed", "b": "3/4", "c": ""} + specs = [{"slug": "a"}, {"slug": "b"}, {"slug": "c"}] + assert get_missing_prerequisites(progress, specs) == ["c"] + + +def test_missing_accepts_plain_slugs(): + progress = {"b": "0/4"} + assert get_missing_prerequisites(progress, ["a", "b"]) == ["a"] + + +def test_missing_threshold(): + progress = {"q": "2/4"} + assert get_missing_prerequisites(progress, [{"slug": "q", "min_percent": 75.0}]) == ["q"] + progress2 = {"q": "3/4"} + assert get_missing_prerequisites(progress2, [{"slug": "q", "min_percent": 75.0}]) == [] + + +def test_missing_empty_and_none(): + assert get_missing_prerequisites({}, []) == [] + assert get_missing_prerequisites(None, [{"slug": "a"}]) == ["a"] diff --git a/services/tests/test_sub_home_api.py b/services/tests/test_sub_home_api.py index a9d8178..88dda55 100644 --- a/services/tests/test_sub_home_api.py +++ b/services/tests/test_sub_home_api.py @@ -183,3 +183,97 @@ def test_bab_lesson_locked_by_prerequisites(client, tmp_path, monkeypatch): assert by_name["dasar.md"]["locked"] is False assert by_name["lanjutan.md"]["completed"] is False assert by_name["lanjutan.md"]["locked"] is False + + +def _make_bab_with_prereq(tmp_path, monkeypatch, prereq_line): + """Buat struktur bab1: dasar.md + lanjutan.md berprasyarat + home.md kosong.""" + bab = tmp_path / "bab1" + bab.mkdir() + (bab / "sub-home.md").write_text( + "# Bab Satu\n\n----Available_Lessons----\n" + "1. [Dasar](lesson/dasar.md)\n" + "2. [Lanjutan](lesson/lanjutan.md)\n", + encoding="utf-8", + ) + (bab / "dasar.md").write_text("# Dasar\nMateri.\n", encoding="utf-8") + (bab / "lanjutan.md").write_text( + "# Lanjutan\n\n---LESSON_INFO---\n**Prerequisites:**\n" + f"{prereq_line}\n---END_LESSON_INFO---\n\nMateri lanjutan.\n", + encoding="utf-8", + ) + (tmp_path / "home.md").write_text("# Home\n\n----Available_Lessons----\n", encoding="utf-8") + + monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path)) + lesson_service.find_lesson_file.cache_clear() + lesson_service.get_lessons.cache_clear() + lesson_service.get_lesson_names.cache_clear() + lesson_service.get_lessons_with_learning_objectives.cache_clear() + + +def test_bab_lesson_unlocked_by_scored_prerequisite(client, tmp_path, monkeypatch): + _make_bab_with_prereq(tmp_path, monkeypatch, "- [Dasar](lesson/dasar.md)") + from routes import lessons as lessons_routes + + # Skor berapa pun (termasuk 0/x) memenuhi prasyarat → Lanjutan tidak terkunci + monkeypatch.setattr(lessons_routes, "get_student_progress", lambda token: {"dasar": "0/4"}) + resp = client.get("/bab/bab1?token=siswa1") + by_name = {l["filename"]: l for l in resp.get_json()["lessons"]} + assert by_name["lanjutan.md"]["locked"] is False + + monkeypatch.setattr(lessons_routes, "get_student_progress", lambda token: {"dasar": "3/4"}) + resp = client.get("/bab/bab1?token=siswa1") + by_name = {l["filename"]: l for l in resp.get_json()["lessons"]} + assert by_name["lanjutan.md"]["locked"] is False + + # Belum dikerjakan → tetap terkunci + monkeypatch.setattr(lessons_routes, "get_student_progress", lambda token: {"dasar": ""}) + resp = client.get("/bab/bab1?token=siswa1") + by_name = {l["filename"]: l for l in resp.get_json()["lessons"]} + assert by_name["lanjutan.md"]["locked"] is True + + +def test_bab_lesson_min_percent_threshold(client, tmp_path, monkeypatch): + _make_bab_with_prereq(tmp_path, monkeypatch, "- [Dasar](lesson/dasar.md) min: 75%") + from routes import lessons as lessons_routes + + # Skor di bawah ambang → tetap terkunci + monkeypatch.setattr(lessons_routes, "get_student_progress", lambda token: {"dasar": "2/4"}) + resp = client.get("/bab/bab1?token=siswa1") + by_name = {l["filename"]: l for l in resp.get_json()["lessons"]} + assert by_name["lanjutan.md"]["locked"] is True + + # Skor mencapai ambang → tidak terkunci + monkeypatch.setattr(lessons_routes, "get_student_progress", lambda token: {"dasar": "3/4"}) + resp = client.get("/bab/bab1?token=siswa1") + by_name = {l["filename"]: l for l in resp.get_json()["lessons"]} + assert by_name["lanjutan.md"]["locked"] is False + + # completed tetap memenuhi walau ada ambang + monkeypatch.setattr( + lessons_routes, + "get_student_progress", + lambda token: {"dasar": "completed"}, + ) + resp = client.get("/bab/bab1?token=siswa1") + by_name = {l["filename"]: l for l in resp.get_json()["lessons"]} + assert by_name["lanjutan.md"]["locked"] is False + + +def test_lesson_detail_missing_prerequisites_with_score(client, tmp_path, monkeypatch): + _make_bab_with_prereq(tmp_path, monkeypatch, "- [Dasar](lesson/dasar.md) min: 75%") + from routes import lessons as lessons_routes + + # Skor di bawah ambang → terkunci + missing_prerequisites berisi slug + monkeypatch.setattr(lessons_routes, "get_student_progress", lambda token: {"dasar": "1/4"}) + resp = client.get("/lesson/lanjutan.json?token=siswa1") + assert resp.status_code == 200 + data = resp.get_json() + assert data["locked"] is True + assert data["missing_prerequisites"] == ["dasar"] + + # Skor di atas ambang → tidak terkunci + monkeypatch.setattr(lessons_routes, "get_student_progress", lambda token: {"dasar": "4/4"}) + resp = client.get("/lesson/lanjutan.json?token=siswa1") + data = resp.get_json() + assert data["locked"] is False + assert data["missing_prerequisites"] == []