From 67ed58a327f6ff93c7ec899668d039327e32cb38 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 13 Aug 2026 20:08:50 +0700 Subject: [PATCH] feat: scope sub-home lesson lists & make sub-home cache mtime-aware Lengkapi rencana sub-home (fitur bab sudah ada sejak 2f689af) dengan perilaku sidebar/navigasi yang akurat per sub-folder: - get_lessons()/get_ordered_lessons_with_learning_objectives() menerima source_path opsional sehingga daftar materi dapat diambil dari sub-home.md alih-alih home.md root - api_lesson: ordered_lessons + prev/next di-scope ke sub-home.md bila folder memilikinya, fallback ke daftar global home.md - get_sub_home_data kini mtime-cached (bukan lru_cache) sehingga edit sub-home.md langsung terlihat tanpa restart; edit juga meng-invalidate find_lesson_file agar materi baru terdeteksi Testing & docs: - test_sub_home.py (unit): _read_md_cached mtime, find_sub_home_for_lesson, get_sub_home_data refresh, ordered_lessons via source_path - test_sub_home_api.py (integration): GET /bab/ + scoped ordered_lessons di /lesson/.json - docs/13-content-sub-home.md (baru), README + docs/02-backend-flask.md diperbarui dengan catatan fallback - elemes.sh: perintah `test` + verifikasi tes sub-home di runclearbuild --- README.md | 49 +++++- docs/02-backend-flask.md | 11 +- docs/13-content-sub-home.md | 126 ++++++++++++++++ elemes.sh | 20 +++ routes/lessons.py | 39 +++-- services/lesson_service.py | 82 +++++++--- services/tests/test_sub_home.py | 224 ++++++++++++++++++++++++++++ services/tests/test_sub_home_api.py | 108 ++++++++++++++ 8 files changed, 619 insertions(+), 40 deletions(-) create mode 100644 docs/13-content-sub-home.md create mode 100644 services/tests/test_sub_home.py create mode 100644 services/tests/test_sub_home_api.py diff --git a/README.md b/README.md index f1d2168..34b4fb8 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,31 @@ Situs LMS ini untuk belajar dasar-dasar pemrograman C. > yang dikenali sistem. Pastikan setiap materi yang ada di folder `content/` > terdaftar di sini. +### 3b. Sub Bab (Opsional): `sub-home.md` dalam Folder + +Untuk mengelompokkan materi ke dalam bab, buat folder satu level di dalam +`content/` (misal `content/bab1/`) dan beri file `sub-home.md` di dalamnya: + +```markdown +# Judul Bab + +Intro bab (opsional). + +----Available_Lessons---- +1. [Materi A](lesson/materi_a.md) +2. [Materi B](lesson/materi_b.md) +``` + +Folder yang memiliki `sub-home.md` otomatis punya halaman bab di +`/bab/`, dan sidebar/navigasi materi di dalam folder tersebut memakai +daftar dari `sub-home.md` (bukan daftar global). + +> **Catatan fallback:** Folder **tanpa** `sub-home.md` tetap memakai daftar +global dari `home.md` — perilaku lama tidak berubah. `sub-home.md` tidak +> dihitung sebagai materi. + +Panduan lengkap: `docs/13-content-sub-home.md`. + ### 4. Buat Materi Pelajaran Setiap file `.md` di folder `content/` adalah satu materi pelajaran. @@ -491,13 +516,16 @@ Folder `examples/` berisi contoh lengkap yang digunakan oleh `./elemes.sh init`: examples/ ├── content/ │ ├── home.md # Halaman utama (7 materi) -│ ├── hello_world.md # Materi dasar: Hello World -│ ├── variabel.md # Materi dasar: Variabel -│ ├── rangkaian_dasar.md # Materi hybrid: C + Circuit -│ ├── led_blink_arduino.md # Arduino: LED Blink + wiring -│ ├── hello_serial_arduino.md # Arduino: Serial Monitor (tanpa wiring) -│ ├── button_input_arduino.md # Arduino: Button + LED input/output -│ └── traffic_light_arduino.md # Arduino: Lampu lalu lintas 3 LED +│ ├── dasar/ # Sub bab: materi pemrograman dasar +│ │ ├── sub-home.md # Halaman bab dasar +│ │ ├── hello_world.md # Materi dasar: Hello World +│ │ ├── variabel.md # Materi dasar: Variabel +│ │ └── ... +│ ├── arduino/ # Sub bab: materi Arduino (Velxio) +│ │ ├── sub-home.md # Halaman bab arduino +│ │ ├── led_blink_arduino.md # Arduino: LED Blink + wiring +│ │ └── ... +│ └── rangkaian_dasar.md # Materi hybrid: C + Circuit ``` ### Jenis Materi @@ -517,6 +545,13 @@ Buat file `.md` baru di `content/`, lalu tambahkan link-nya di `content/home.md` Daftar lesson disinkronkan otomatis ke database saat aplikasi start — tidak perlu perintah manual. +**Q: Bagaimana mengelompokkan materi ke dalam bab?** +Buat folder satu level di dalam `content/` (misal `content/bab1/`), letakkan +file `sub-home.md` di folder itu, dan daftarkan materi bab di bagian +`----Available_Lessons----` file tersebut. Folder punya halaman sendiri di +`/bab/` dan sidebar/navigasi materinya otomatis scoped ke bab itu. +Detail: `docs/13-content-sub-home.md`. + **Q: Bagaimana menambah siswa baru?** Buka halaman **Laporan Progress** (`/progress`), Export CSV, tambahkan baris baru (`student_id` kosong + token unik + nama siswa), lalu Import. Detail diff --git a/docs/02-backend-flask.md b/docs/02-backend-flask.md index c2a2b02..2bd6373 100644 --- a/docs/02-backend-flask.md +++ b/docs/02-backend-flask.md @@ -24,8 +24,10 @@ The backend is built with Flask, providing API endpoints for the SvelteKit front ### Lessons (`routes/lessons.py`) - `def api_lessons():` (GET `/lessons`) Returns a list of all lessons and the rendered `home.md` content via `lesson_service.get_ordered_lessons_with_learning_objectives()`. +- `def api_bab(folder):` (GET `/bab/`) + Returns parsed `sub-home.md` data for a folder (title, intro HTML, lesson list) via `lesson_service.get_sub_home_data()`. Returns 404 when the folder has no `sub-home.md`. - `def api_lesson(filename):` (GET `/lesson/.json`) - Returns the fully parsed lesson data (content, initial code, circuits, key texts, active tabs) via `lesson_service.render_markdown_content(filepath)`. + Returns the fully parsed lesson data (content, initial code, circuits, key texts, active tabs) via `lesson_service.render_markdown_content(filepath)`. When the lesson lives in a folder that has a `sub-home.md`, `ordered_lessons` (sidebar) and prev/next navigation are scoped to that folder's lesson list; otherwise it falls back to the global `home.md` list. - `def get_key_text(filename):` (GET `/get-key-text/`) Returns only the required keywords for a specific lesson without exposing the full content logic. @@ -62,10 +64,15 @@ are never persisted and cannot be recovered or exported. ### Lesson Service (`services/lesson_service.py`) Parses Markdown files to extract content and configuration. -- `def get_ordered_lessons_with_learning_objectives(progress=None):` Returns lessons ordered as they appear in `home.md`, optionally injected with user progress status. +- `def get_lessons(source_path=None):` Returns lessons listed in the `Available_Lessons` section of `home.md` (or of `source_path` when given, e.g. a `sub-home.md`). +- `def get_ordered_lessons_with_learning_objectives(progress=None, source_path=None):` Returns lessons ordered as they appear in `home.md` (or `source_path`), optionally injected with user progress status. +- `def find_sub_home_for_lesson(file_path):` Returns `(sub_home_path, folder_name)` when the lesson's folder (one level inside `content/`) has a `sub-home.md`, else `(None, None)`. +- `def get_sub_home_data(folder_name):` Parses a folder's `sub-home.md` (title, intro HTML, lesson list) with an mtime-based cache so edits to the file are picked up without restart. - `def render_markdown_content(file_path):` The core parsing function. Uses regex to extract markers like `---INITIAL_CODE---`, `---VELXIO_CIRCUIT---`, etc. It identifies the `active_tabs` needed for the frontend. - `def _parse_flashcards(text):` Specifically parses `---QUIZ_FLASHCARD---` blocks into a structured JSON array for the frontend MCQ/Flashcard component. +See `docs/13-content-sub-home.md` for the author-facing guide on writing `sub-home.md`. + ## Compiler Framework (`compiler/`) The compilation logic is abstracted via a factory pattern. diff --git a/docs/13-content-sub-home.md b/docs/13-content-sub-home.md new file mode 100644 index 0000000..0707634 --- /dev/null +++ b/docs/13-content-sub-home.md @@ -0,0 +1,126 @@ +# Sub Bab (`sub-home.md`) — Mengelompokkan Materi dalam Folder + +**Tanggal:** 2026-08-13 +**Status:** Aktif +**Lokasi kode:** Backend `services/lesson_service.py` + `routes/lessons.py` + +--- + +## 1. Latar Belakang + +Konten LMS biasa ditulis sebagai file `.md` langsung di `content/` dan didaftarkan +satu per satu di `content/home.md`. Ketika materi bertambah banyak, daftar global +menjadi panjang dan susah dirawat. + +Fitur **sub-home** memungkinkan penulis menaruh sekelompok materi di dalam folder +satu level di `content/` (misal `content/bab1/`), lengkap dengan halaman bab +sendiri (`/bab/`) dan daftar materi yang akurat untuk folder itu. + +## 2. Cara Kerja + +### Struktur Folder + +``` +content/ +├── home.md # Halaman utama + daftar global (fallback) +├── hello_world.md # Materi di root (tanpa sub bab) +└── bab1/ # Satu folder = satu sub bab (level satu) + ├── sub-home.md # "Halaman bab" — judul, intro, daftar materi + ├── materi_a.md + ├── materi_b.md + └── materi_c.md +``` + +### Format `sub-home.md` + +```markdown +# Judul Bab + +Intro / deskripsi bab (opsional). + +----Available_Lessons---- +1. [Materi A](lesson/materi_a.md) +2. [Materi B](lesson/materi_b.md) +3. [Materi C](lesson/materi_c.md) +``` + +Aturan: + +1. **Baris pertama `# Judul`** menjadi judul bab (ditampilkan di halaman `/bab/`). +2. **Teks sebelum `----Available_Lessons----`** menjadi intro bab (dirender markdown). +3. **Bagian setelah `----Available_Lessons----`** adalah daftar materi folder — + format sama dengan `home.md` (`[Judul](path.md)`, boleh dengan awalan `lesson/`). +4. **`sub-home.md` tidak dihitung sebagai materi** — di-skip oleh `find_lesson_file()` + dan `_parse_lesson_links()`, jadi tidak muncul di daftar lesson global maupun + sinkronisasi lesson registry. + +### Fallback + +- Folder **tanpa** `sub-home.md` tidak memiliki halaman bab — materinya tetap + diambil dari daftar global `home.md` (perilaku lama, tidak berubah). +- Lesson di dalam folder yang **memiliki** `sub-home.md` mendapat `ordered_lessons` + (sidebar) dan navigasi prev/next **scoped ke folder itu**. Bila lesson belum + dicantumkan di `sub-home.md`, fallback ke daftar global `home.md`. + +## 3. API + +### `GET /bab/` (via proxy frontend: `/api/bab/`) + +Mengembalikan JSON isi `sub-home.md`: + +```json +{ + "title": "Judul Bab", + "intro_html": "

Intro bab...

", + "lessons": [ + { "filename": "materi_a.md", "title": "Materi A", "description": "...", "path": "...", "prerequisite_titles": [] }, + { "filename": "materi_b.md", "title": "Materi B", "description": "...", "path": "...", "prerequisite_titles": [] } + ], + "folder": "bab1", + "url": "/bab/bab1" +} +``` + +Respons `404` bila folder tidak ada atau tidak memiliki `sub-home.md`. + +### `GET /lesson/.json` + +Bila lesson berada di folder yang memiliki `sub-home.md`, field `ordered_lessons` +diisi daftar materi dari `sub-home.md` folder itu (bukan dari `home.md` global), +dan field `sub_home` berisi metadata folder: + +```json +{ + "ordered_lessons": [ { "filename": "materi_a.md", "title": "Materi A" }, "..."], + "sub_home": { "folder": "bab1", "url": "/bab/bab1", "title": "Judul Bab" } +} +``` + +Bila tidak ada `sub-home.md`, `ordered_lessons` memakai daftar global `home.md` +dan `sub_home` bernilai `null`. + +## 4. Cache + +Parsing `sub-home.md` di-cache berbasis **mtime**: setiap kali file diubah, +hasil parsing otomatis segar tanpa restart aplikasi (tidak memakai `lru_cache` +yang bisa mengembalikan data basi). Perubahan pada `sub-home.md` juga +meng-invalidate cache `find_lesson_file()` agar materi yang baru ditambahkan +langsung terdeteksi. + +## 5. Contoh Siap Pakai + +Contoh lengkap ada di `examples/content/`: + +- `examples/content/dasar/sub-home.md` +- `examples/content/arduino/sub-home.md` + +## 6. FAQ + +**Q: Apakah sub bab bisa bersarang (folder di dalam folder)?** +Tidak. Fitur ini dirancang untuk folder **satu level** di dalam `content/`. +Sub bab bertingkat di luar lingkup. + +**Q: Apakah materi di dalam folder harus didaftarkan juga di `home.md`?** +Tidak wajib. Materi folder cukup didaftarkan di `sub-home.md` folder tersebut. +Lesson yang tidak ada di `home.md` tidak akan disinkronkan ke lesson registry +(progress per-lesson di halaman guru mengikuti daftar `home.md` global). diff --git a/elemes.sh b/elemes.sh index e2cc8fb..70c5171 100755 --- a/elemes.sh +++ b/elemes.sh @@ -184,6 +184,20 @@ runbuild | runclearbuild) run_compose up --force-recreate -d echo "✅ Elemes berhasil dijalankan!" db_init + ;;& +runbuild | runclearbuild) + # Verifikasi tes sub-home setelah build bersih (runclearbuild) — memakai + # fake DATABASE_URL kosong agar tes yang butuh PostgreSQL di-skip dan tidak + # mengubah data produksi. Gagal bila ada tes baru yang rusak. + if [ "$1" = "runclearbuild" ]; then + echo "🧪 Verifikasi: unit & API test sub-home..." + if ! compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \ + elemes python -m pytest services/tests/test_sub_home.py services/tests/test_sub_home_api.py -q; then + echo "❌ Tes sub-home gagal. Periksa output di atas sebelum deploy." + exit 1 + fi + echo "✅ Tes sub-home lulus." + fi ;; run) echo "🚀 Menjalankan container..." @@ -191,6 +205,11 @@ run) echo "✅ Elemes berhasil dijalankan!" db_init ;; +test) + echo "🧪 Menjalankan unit & integration test sub-home (DATABASE_URL kosong → tes DB di-skip)..." + compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \ + elemes python -m pytest services/tests/test_sub_home.py services/tests/test_sub_home_api.py -q + ;; exportall) echo "📦 === Mengekspor Semua Image (Pre-Compiled Bundle) ===" TAR_FILE="lms-precompiled.tar" @@ -427,6 +446,7 @@ dbrestore) echo " ./elemes.sh teacher # Buat/update akun guru (upsert, prompt nama & token)" echo " ./elemes.sh dbbackup # Backup database → backups/elemes_.sql" echo " ./elemes.sh dbrestore # Restore backup terbaru dari backups/" + echo " ./elemes.sh test # Jalankan unit & API test sub-home (tanpa DB)" echo " ./elemes.sh loadtest # Menjalankan utilitas simulasi Load Test (Locust)" ;; esac diff --git a/routes/lessons.py b/routes/lessons.py index 455ff37..03ae68b 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -127,7 +127,23 @@ def api_lesson(filename): lesson_progress_status = status lesson_completed = status not in (None, '', 'not_started') - all_lessons = get_ordered_lessons_with_learning_objectives(progress) + # Detect sub-home for this lesson's folder (untuk sidebar & navigasi) + sub_home_path, sub_home_folder = find_sub_home_for_lesson(file_path) + sub_home = None + if sub_home_path and sub_home_folder: + sub_home_data = get_sub_home_data(sub_home_folder) + sub_home = { + 'folder': sub_home_folder, + 'url': f'/bab/{sub_home_folder}', + 'title': sub_home_data['title'] if sub_home_data else sub_home_folder.replace('_', ' ').title(), + } + + # ordered_lessons: daftar materi scoped ke sub-home.md bila folder memilikinya, + # fallback ke daftar global home.md bila tidak. + all_lessons = get_ordered_lessons_with_learning_objectives( + progress, + source_path=sub_home_path if (sub_home_path and sub_home_folder) else None, + ) current_idx = -1 current_lesson_meta = None @@ -137,6 +153,16 @@ def api_lesson(filename): current_lesson_meta = les break + # Bila lesson tidak ada di daftar sub-home (mis. belum dicantumkan di + # sub-home.md), fallback ke daftar global home.md agar sidebar tidak kosong. + if current_idx < 0: + all_lessons = get_ordered_lessons_with_learning_objectives(progress) + for i, les in enumerate(all_lessons): + if les['filename'] == full_filename: + current_idx = i + current_lesson_meta = les + break + # Prerequisite check is_locked = False missing_prereqs = [] @@ -150,17 +176,6 @@ def api_lesson(filename): 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 - # Detect sub-home for this lesson's folder - sub_home_path, sub_home_folder = find_sub_home_for_lesson(file_path) - sub_home = None - if sub_home_path and sub_home_folder: - sub_home_data = get_sub_home_data(sub_home_folder) - sub_home = { - 'folder': sub_home_folder, - 'url': f'/bab/{sub_home_folder}', - 'title': sub_home_data['title'] if sub_home_data else sub_home_folder.replace('_', ' ').title(), - } - # Derive default language from active_tabs (frontend manages switching) if 'python' in active_tabs and 'c' not in active_tabs: programming_language = 'python' diff --git a/services/lesson_service.py b/services/lesson_service.py index 30fef00..e47015a 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -21,6 +21,12 @@ _file_cache_lock = Lock() _markdown_cache = {} _markdown_lock = Lock() +# Parsed sub-home data cache (folder -> {mtime, data}) — mtime-based, agar +# hasil parsing sub-home.md selalu segar bila file-nya berubah (bukan lru_cache +# yang bisa mengembalikan data basi sampai proses restart). +_sub_home_cache = {} # {folder_name: {'mtime': float, 'data': dict}} +_sub_home_cache_lock = Lock() + # Pre-computed absolute path for home.md (avoids repeated syscall on hot path) _HOME_MD_PATH = os.path.abspath(os.path.join(CONTENT_DIR, "home.md")) @@ -65,6 +71,13 @@ def _read_md_cached(path): with _markdown_lock: _markdown_cache.clear() + # If this is a sub-home.md, invalidate its parsed data cache so edits to + # the file (new lessons, reorder) show up without restarting the app. + if os.path.basename(path) == 'sub-home.md': + with _sub_home_cache_lock: + _sub_home_cache.clear() + find_lesson_file.cache_clear() + return content @@ -118,14 +131,18 @@ def find_lesson_file(filename): @lru_cache(maxsize=32) -def get_lessons(): - """Get lessons from the Available_Lessons section in home.md.""" +def get_lessons(source_path=None): + """Get lessons from the Available_Lessons section in home.md. + + `source_path` opsional: bila diberikan, daftar materi diambil dari file + markdown tersebut (mis. sub-home.md) alih-alih home.md root. + """ lessons = [] - home_content = _read_home_md() - if not home_content: + source_content = _read_md_cached(source_path) if source_path else _read_home_md() + if not source_content: return lessons - for link_text, filename in _parse_lesson_links(home_content): + for link_text, filename in _parse_lesson_links(source_content): file_path = find_lesson_file(filename) if not file_path: continue @@ -172,15 +189,12 @@ def get_lesson_names(): return names -@lru_cache(maxsize=32) -def get_lessons_with_learning_objectives(): - """Get lessons with learning objectives extracted from LESSON_INFO sections.""" +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.""" lessons = [] - home_content = _read_home_md() - if not home_content: - return lessons - for link_text, filename in _parse_lesson_links(home_content): + for link_text, filename in lesson_links: file_path = find_lesson_file(filename) if not file_path: continue @@ -263,12 +277,29 @@ def get_lessons_with_learning_objectives(): return lessons -def get_ordered_lessons_with_learning_objectives(progress=None): - """Get lessons ordered per home.md with completion status from progress dict.""" +@lru_cache(maxsize=32) +def get_lessons_with_learning_objectives(): + """Get lessons with learning objectives extracted from LESSON_INFO sections.""" home_content = _read_home_md() - lesson_links = _parse_lesson_links(home_content) if home_content else [] + if not home_content: + return [] + return _build_lessons_with_objectives(_parse_lesson_links(home_content)) - all_lessons = get_lessons_with_learning_objectives() + +def get_ordered_lessons_with_learning_objectives(progress=None, source_path=None): + """Get lessons ordered per home.md with completion status from progress dict. + + `source_path` opsional: bila diberikan (mis. path ke sub-home.md), daftar + materi & urutannya diambil dari file tersebut, bukan dari home.md root. + """ + if source_path: + content = _read_md_cached(source_path) + lesson_links = _parse_lesson_links(content) if content else [] + all_lessons = _build_lessons_with_objectives(lesson_links) + else: + home_content = _read_home_md() + lesson_links = _parse_lesson_links(home_content) if home_content else [] + all_lessons = get_lessons_with_learning_objectives() # Build title -> slug mapping for prerequisite resolution title_to_slug = {lesson['title']: lesson['filename'].replace('.md', '') for lesson in all_lessons} @@ -353,9 +384,8 @@ def find_sub_home_for_lesson(file_path): return sub_home_path, folder_name -@lru_cache(maxsize=32) def get_sub_home_data(folder_name): - """Return parsed sub-home data for a given folder name. + """Return parsed sub-home data for a given folder name (mtime-cached). Returns dict with keys: title, intro_html, lessons, folder, url. Returns None if no sub-home.md found or unreadable. @@ -373,6 +403,15 @@ def get_sub_home_data(folder_name): return None except OSError: return None + # mtime-based cache: refresh bila sub-home.md diubah, tanpa menunggu restart + try: + current_mtime = os.path.getmtime(sub_home_path) + except OSError: + current_mtime = 0.0 + with _sub_home_cache_lock: + cached = _sub_home_cache.get(folder_name) + if cached and cached['mtime'] == current_mtime: + return cached['data'] content = _read_md_cached(sub_home_path) if not content: return None @@ -444,13 +483,18 @@ def get_sub_home_data(folder_name): 'prerequisite_titles': prerequisite_titles, }) - return { + data = { 'title': title, 'intro_html': intro_html, 'lessons': lessons, 'folder': folder_name, 'url': f'/bab/{folder_name}', } + with _sub_home_cache_lock: + if len(_sub_home_cache) >= 64: + _sub_home_cache.clear() + _sub_home_cache[folder_name] = {'mtime': current_mtime, 'data': data} + return data # --------------------------------------------------------------------------- diff --git a/services/tests/test_sub_home.py b/services/tests/test_sub_home.py new file mode 100644 index 0000000..a3aca56 --- /dev/null +++ b/services/tests/test_sub_home.py @@ -0,0 +1,224 @@ +""" +Unit test untuk helper sub-home di lesson_service: + +- `_read_md_cached`: membaca file dengan cache berbasis mtime; hasil harus + segar bila file diubah (mtime berubah). +- `find_sub_home_for_lesson`: mendeteksi `sub-home.md` di folder induk satu + level dari sebuah file lesson (ada / tidak ada). +- `get_sub_home_data`: mem-parsing sub-home.md (title, intro, daftar lesson) + dan me-refresh hasilnya bila file berubah. +- `get_ordered_lessons_with_learning_objectives(source_path=...)`: daftar + materi diambil dari sub-home.md bila source_path diberikan, fallback home.md. +""" + +import os +import time + +import pytest + +from services import lesson_service +from services.lesson_service import ( + _read_md_cached, + find_sub_home_for_lesson, + get_ordered_lessons_with_learning_objectives, + get_sub_home_data, +) + + +@pytest.fixture(autouse=True) +def _clear_caches(): + """Bersihkan cache lintas test supaya hasil tidak basi antar kasus.""" + yield + lesson_service._file_cache.clear() + lesson_service._sub_home_cache.clear() + 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 _touch_mtime(path): + """Paksa mtime berubah (beberapa FS punya resolusi nanodetik).""" + old = os.path.getmtime(path) + os.utime(path, (old + 5, old + 5)) + + +# --------------------------------------------------------------------------- +# _read_md_cached +# --------------------------------------------------------------------------- + + +def test_read_md_cached_returns_content(tmp_path): + p = tmp_path / "sub-home.md" + p.write_text("# Bab Satu\n\n----Available_Lessons----\n", encoding="utf-8") + assert _read_md_cached(str(p)) == "# Bab Satu\n\n----Available_Lessons----\n" + + +def test_read_md_cached_refreshes_on_mtime_change(tmp_path): + p = tmp_path / "sub-home.md" + p.write_text("versi 1", encoding="utf-8") + assert _read_md_cached(str(p)) == "versi 1" + + p.write_text("versi 2", encoding="utf-8") + _touch_mtime(p) + assert _read_md_cached(str(p)) == "versi 2" + + +def test_read_md_cached_missing_file_returns_empty(tmp_path): + assert _read_md_cached(str(tmp_path / "tidak-ada.md")) == "" + + +# --------------------------------------------------------------------------- +# find_sub_home_for_lesson +# --------------------------------------------------------------------------- + + +def test_find_sub_home_for_lesson_found(tmp_path): + bab = tmp_path / "bab1" + bab.mkdir() + (bab / "sub-home.md").write_text("# Bab 1", encoding="utf-8") + lesson = bab / "hello.md" + lesson.write_text("# Hello", encoding="utf-8") + + sub_home_path, folder_name = find_sub_home_for_lesson(str(lesson)) + assert folder_name == "bab1" + assert os.path.basename(sub_home_path) == "sub-home.md" + + +def test_find_sub_home_for_lesson_missing(tmp_path): + bab = tmp_path / "bab1" + bab.mkdir() + lesson = bab / "hello.md" + lesson.write_text("# Hello", encoding="utf-8") + + assert find_sub_home_for_lesson(str(lesson)) == (None, None) + + +def test_find_sub_home_for_lesson_none_path(): + assert find_sub_home_for_lesson(None) == (None, None) + + +# --------------------------------------------------------------------------- +# get_sub_home_data +# --------------------------------------------------------------------------- + + +def _write_sub_home_fixture(base, folder="bab1"): + bab = base / folder + bab.mkdir(parents=True, exist_ok=True) + (bab / "sub-home.md").write_text( + "# Bab Satu\n\nIntro bab satu.\n\n" + "----Available_Lessons----\n" + "1. [Hello, World!](lesson/hello_world.md)\n" + "2. [Variabel](lesson/variabel.md)\n" + "3. [Percabangan](lesson/percabangan.md)\n", + encoding="utf-8", + ) + (bab / "hello_world.md").write_text( + "# Hello, World!\nMateri pertama.\n", encoding="utf-8" + ) + (bab / "variabel.md").write_text("# Variabel\nMateri kedua.\n", encoding="utf-8") + (bab / "percabangan.md").write_text("# Percabangan\nMateri ketiga.\n", encoding="utf-8") + return bab + + +def test_get_sub_home_data_parses(tmp_path, monkeypatch): + _write_sub_home_fixture(tmp_path) + monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path)) + + data = get_sub_home_data("bab1") + assert data is not None + assert data["title"] == "Bab Satu" + assert "Intro bab satu" in data["intro_html"] + assert data["folder"] == "bab1" + assert data["url"] == "/bab/bab1" + filenames = [l["filename"] for l in data["lessons"]] + assert filenames == ["hello_world.md", "variabel.md", "percabangan.md"] + + +def test_get_sub_home_data_missing_folder_returns_none(tmp_path, monkeypatch): + monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path)) + assert get_sub_home_data("tidak-ada") is None + + +def test_get_sub_home_data_missing_file_returns_none(tmp_path, monkeypatch): + (tmp_path / "bab1").mkdir() + monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path)) + assert get_sub_home_data("bab1") is None + + +def test_get_sub_home_data_refreshes_on_change(tmp_path, monkeypatch): + _write_sub_home_fixture(tmp_path) + monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path)) + + data = get_sub_home_data("bab1") + assert [l["filename"] for l in data["lessons"]] == [ + "hello_world.md", + "variabel.md", + "percabangan.md", + ] + + # Ubah sub-home.md → panggilan berikutnya harus memakai data baru + bab = tmp_path / "bab1" + (bab / "sub-home.md").write_text( + "# Bab Satu\n\n----Available_Lessons----\n" + "1. [Hello, World!](lesson/hello_world.md)\n", + encoding="utf-8", + ) + _touch_mtime(bab / "sub-home.md") + + data2 = get_sub_home_data("bab1") + assert [l["filename"] for l in data2["lessons"]] == ["hello_world.md"] + + +# --------------------------------------------------------------------------- +# get_ordered_lessons_with_learning_objectives(source_path=...) +# --------------------------------------------------------------------------- + + +def test_ordered_lessons_source_path_uses_sub_home(tmp_path, monkeypatch): + _write_sub_home_fixture(tmp_path) + # home.md global: hanya 1 lesson agar beda dengan daftar sub-home + (tmp_path / "home.md").write_text( + "# Home\n\n----Available_Lessons----\n" + "1. [Hello, World!](lesson/hello_world.md)\n", + encoding="utf-8", + ) + monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path)) + lesson_service.get_lessons.cache_clear() + + sub_home_path = str(tmp_path / "bab1" / "sub-home.md") + lessons = get_ordered_lessons_with_learning_objectives(source_path=sub_home_path) + filenames = [l["filename"] for l in lessons] + assert filenames == ["hello_world.md", "variabel.md", "percabangan.md"] + + +def test_ordered_lessons_without_source_path_uses_home(tmp_path, monkeypatch): + _write_sub_home_fixture(tmp_path) + (tmp_path / "home.md").write_text( + "# Home\n\n----Available_Lessons----\n" + "1. [Hello, World!](lesson/hello_world.md)\n", + encoding="utf-8", + ) + monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path)) + lesson_service.get_lessons.cache_clear() + + lessons = get_ordered_lessons_with_learning_objectives() + filenames = [l["filename"] for l in lessons] + assert filenames == ["hello_world.md"] + + +def test_ordered_lessons_with_completion_status(tmp_path, monkeypatch): + _write_sub_home_fixture(tmp_path) + (tmp_path / "home.md").write_text( + "# Home\n\n----Available_Lessons----\n" + "1. [Hello, World!](lesson/hello_world.md)\n", + encoding="utf-8", + ) + monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path)) + lesson_service.get_lessons.cache_clear() + + progress = {"hello_world": "completed"} + lessons = get_ordered_lessons_with_learning_objectives(progress=progress) + assert lessons[0]["completed"] is True + assert lessons[0]["filename"] == "hello_world.md" diff --git a/services/tests/test_sub_home_api.py b/services/tests/test_sub_home_api.py new file mode 100644 index 0000000..9bb3a00 --- /dev/null +++ b/services/tests/test_sub_home_api.py @@ -0,0 +1,108 @@ +""" +Integration test untuk endpoint sub-home / bab: + +1. `GET /bab/` — mengembalikan JSON isi sub-home.md. +2. `GET /lesson/.json` — `ordered_lessons` diambil dari sub-home.md + bila folder memiliki file tersebut (fallback ke home.md bila tidak). + +Menggunakan Flask test client (`app`/`client` fixture dari conftest) dengan +CONTENT_DIR yang di-point ke direktori test sementara. +""" + +import os + +import pytest + +from services import lesson_service + + +@pytest.fixture() +def content_dir(tmp_path, monkeypatch): + """Buat struktur content test: home.md + bab1/ dengan sub-home.md & 3 lesson.""" + bab = tmp_path / "bab1" + bab.mkdir() + (bab / "sub-home.md").write_text( + "# Bab Satu\n\nIntro bab satu.\n\n" + "----Available_Lessons----\n" + "1. [Hello, World!](lesson/hello_world.md)\n" + "2. [Variabel](lesson/variabel.md)\n" + "3. [Percabangan](lesson/percabangan.md)\n", + encoding="utf-8", + ) + for name in ("hello_world", "variabel", "percabangan"): + (bab / f"{name}.md").write_text(f"# {name.replace('_', ' ').title()}\nMateri.\n", encoding="utf-8") + + (tmp_path / "home.md").write_text( + "# Home\n\n----Available_Lessons----\n" + "1. [Hello, World!](lesson/hello_world.md)\n" + "2. [Variabel](lesson/variabel.md)\n" + "3. [Percabangan](lesson/percabangan.md)\n" + "4. [Lesson Global](lesson/lesson_global.md)\n", + encoding="utf-8", + ) + (tmp_path / "lesson_global.md").write_text("# Lesson Global\nMateri global.\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() + yield 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_endpoint_returns_sub_home_json(client, content_dir): + resp = client.get("/bab/bab1") + assert resp.status_code == 200 + data = resp.get_json() + assert data["title"] == "Bab Satu" + assert "Intro bab satu" in data["intro_html"] + assert data["folder"] == "bab1" + assert [l["filename"] for l in data["lessons"]] == [ + "hello_world.md", + "variabel.md", + "percabangan.md", + ] + + +def test_bab_endpoint_missing_folder_404(client, content_dir): + resp = client.get("/bab/tidak-ada") + assert resp.status_code == 404 + + +def test_lesson_ordered_lessons_scoped_to_sub_home(client, content_dir): + resp = client.get("/lesson/variabel.json") + assert resp.status_code == 200 + data = resp.get_json() + # Lesson di dalam folder dengan sub-home.md → ordered_lessons dari sub-home.md + assert [l["filename"] for l in data["ordered_lessons"]] == [ + "hello_world.md", + "variabel.md", + "percabangan.md", + ] + assert data["sub_home"] is not None + assert data["sub_home"]["folder"] == "bab1" + + +def test_lesson_outside_sub_home_uses_home_fallback(client, content_dir): + resp = client.get("/lesson/lesson_global.json") + assert resp.status_code == 200 + data = resp.get_json() + # Lesson di luar folder sub-home → ordered_lessons fallback ke home.md (semua) + assert [l["filename"] for l in data["ordered_lessons"]] == [ + "hello_world.md", + "variabel.md", + "percabangan.md", + "lesson_global.md", + ] + assert data["sub_home"] is None + + +def test_lesson_prev_next_within_sub_home(client, content_dir): + resp = client.get("/lesson/variabel.json") + data = resp.get_json() + assert data["prev_lesson"]["filename"] == "hello_world.md" + assert data["next_lesson"]["filename"] == "percabangan.md"