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:
parent
7e17da46c2
commit
67ed58a327
49
README.md
49
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/<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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
20
elemes.sh
20
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_<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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue