feat(prereq): dukung ambang minimum (min: N%) pada prasyarat materi

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
This commit is contained in:
a2nr 2026-08-17 21:57:10 +07:00
parent 16031fa728
commit ac10692b61
8 changed files with 408 additions and 62 deletions

View File

@ -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/<slug>.json`
Bila lesson berada di folder yang memiliki `sub-home.md`, field `ordered_lessons`

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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 "<earned>/<total>" (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 = {

View File

@ -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"]

View File

@ -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"] == []