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/<folder> + scoped
  ordered_lessons di /lesson/<slug>.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
This commit is contained in:
a2nr 2026-08-13 20:08:50 +07:00
parent 7e17da46c2
commit 67ed58a327
8 changed files with 619 additions and 40 deletions

View File

@ -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/<folder>`, 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/<folder>` 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

View File

@ -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/<folder>`)
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/<slug>.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/<slug>`)
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.

126
docs/13-content-sub-home.md Normal file
View File

@ -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/<folder>`) 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/<folder>`).
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/<folder>` (via proxy frontend: `/api/bab/<folder>`)
Mengembalikan JSON isi `sub-home.md`:
```json
{
"title": "Judul Bab",
"intro_html": "<p>Intro bab...</p>",
"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/<slug>.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).

View File

@ -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_<ts>.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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,108 @@
"""
Integration test untuk endpoint sub-home / bab:
1. `GET /bab/<folder>` mengembalikan JSON isi sub-home.md.
2. `GET /lesson/<slug>.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"