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:
parent
16031fa728
commit
ac10692b61
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"] == []
|
||||
|
|
|
|||
Loading…
Reference in New Issue