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/`
|
> yang dikenali sistem. Pastikan setiap materi yang ada di folder `content/`
|
||||||
> terdaftar di sini.
|
> 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
|
### 4. Buat Materi Pelajaran
|
||||||
|
|
||||||
Setiap file `.md` di folder `content/` adalah satu 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/
|
examples/
|
||||||
├── content/
|
├── content/
|
||||||
│ ├── home.md # Halaman utama (7 materi)
|
│ ├── home.md # Halaman utama (7 materi)
|
||||||
│ ├── hello_world.md # Materi dasar: Hello World
|
│ ├── dasar/ # Sub bab: materi pemrograman dasar
|
||||||
│ ├── variabel.md # Materi dasar: Variabel
|
│ │ ├── sub-home.md # Halaman bab dasar
|
||||||
│ ├── rangkaian_dasar.md # Materi hybrid: C + Circuit
|
│ │ ├── hello_world.md # Materi dasar: Hello World
|
||||||
│ ├── led_blink_arduino.md # Arduino: LED Blink + wiring
|
│ │ ├── variabel.md # Materi dasar: Variabel
|
||||||
│ ├── hello_serial_arduino.md # Arduino: Serial Monitor (tanpa wiring)
|
│ │ └── ...
|
||||||
│ ├── button_input_arduino.md # Arduino: Button + LED input/output
|
│ ├── arduino/ # Sub bab: materi Arduino (Velxio)
|
||||||
│ └── traffic_light_arduino.md # Arduino: Lampu lalu lintas 3 LED
|
│ │ ├── sub-home.md # Halaman bab arduino
|
||||||
|
│ │ ├── led_blink_arduino.md # Arduino: LED Blink + wiring
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── rangkaian_dasar.md # Materi hybrid: C + Circuit
|
||||||
```
|
```
|
||||||
|
|
||||||
### Jenis Materi
|
### 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
|
Daftar lesson disinkronkan otomatis ke database saat aplikasi start — tidak
|
||||||
perlu perintah manual.
|
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?**
|
**Q: Bagaimana menambah siswa baru?**
|
||||||
Buka halaman **Laporan Progress** (`/progress`), Export CSV, tambahkan baris
|
Buka halaman **Laporan Progress** (`/progress`), Export CSV, tambahkan baris
|
||||||
baru (`student_id` kosong + token unik + nama siswa), lalu Import. Detail
|
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`)
|
### Lessons (`routes/lessons.py`)
|
||||||
- `def api_lessons():` (GET `/lessons`)
|
- `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()`.
|
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`)
|
- `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>`)
|
- `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.
|
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`)
|
### Lesson Service (`services/lesson_service.py`)
|
||||||
Parses Markdown files to extract content and configuration.
|
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 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.
|
- `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/`)
|
## Compiler Framework (`compiler/`)
|
||||||
|
|
||||||
The compilation logic is abstracted via a factory pattern.
|
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
|
run_compose up --force-recreate -d
|
||||||
echo "✅ Elemes berhasil dijalankan!"
|
echo "✅ Elemes berhasil dijalankan!"
|
||||||
db_init
|
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)
|
run)
|
||||||
echo "🚀 Menjalankan container..."
|
echo "🚀 Menjalankan container..."
|
||||||
|
|
@ -191,6 +205,11 @@ run)
|
||||||
echo "✅ Elemes berhasil dijalankan!"
|
echo "✅ Elemes berhasil dijalankan!"
|
||||||
db_init
|
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)
|
exportall)
|
||||||
echo "📦 === Mengekspor Semua Image (Pre-Compiled Bundle) ==="
|
echo "📦 === Mengekspor Semua Image (Pre-Compiled Bundle) ==="
|
||||||
TAR_FILE="lms-precompiled.tar"
|
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 teacher # Buat/update akun guru (upsert, prompt nama & token)"
|
||||||
echo " ./elemes.sh dbbackup # Backup database → backups/elemes_<ts>.sql"
|
echo " ./elemes.sh dbbackup # Backup database → backups/elemes_<ts>.sql"
|
||||||
echo " ./elemes.sh dbrestore # Restore backup terbaru dari backups/"
|
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)"
|
echo " ./elemes.sh loadtest # Menjalankan utilitas simulasi Load Test (Locust)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,23 @@ def api_lesson(filename):
|
||||||
lesson_progress_status = status
|
lesson_progress_status = status
|
||||||
lesson_completed = status not in (None, '', 'not_started')
|
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_idx = -1
|
||||||
current_lesson_meta = None
|
current_lesson_meta = None
|
||||||
|
|
@ -137,6 +153,16 @@ def api_lesson(filename):
|
||||||
current_lesson_meta = les
|
current_lesson_meta = les
|
||||||
break
|
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
|
# Prerequisite check
|
||||||
is_locked = False
|
is_locked = False
|
||||||
missing_prereqs = []
|
missing_prereqs = []
|
||||||
|
|
@ -150,17 +176,6 @@ def api_lesson(filename):
|
||||||
prev_lesson = all_lessons[current_idx - 1] if current_idx > 0 else None
|
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
|
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)
|
# Derive default language from active_tabs (frontend manages switching)
|
||||||
if 'python' in active_tabs and 'c' not in active_tabs:
|
if 'python' in active_tabs and 'c' not in active_tabs:
|
||||||
programming_language = 'python'
|
programming_language = 'python'
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ _file_cache_lock = Lock()
|
||||||
_markdown_cache = {}
|
_markdown_cache = {}
|
||||||
_markdown_lock = Lock()
|
_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)
|
# 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"))
|
_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:
|
with _markdown_lock:
|
||||||
_markdown_cache.clear()
|
_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
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,14 +131,18 @@ def find_lesson_file(filename):
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
@lru_cache(maxsize=32)
|
||||||
def get_lessons():
|
def get_lessons(source_path=None):
|
||||||
"""Get lessons from the Available_Lessons section in home.md."""
|
"""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 = []
|
lessons = []
|
||||||
home_content = _read_home_md()
|
source_content = _read_md_cached(source_path) if source_path else _read_home_md()
|
||||||
if not home_content:
|
if not source_content:
|
||||||
return lessons
|
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)
|
file_path = find_lesson_file(filename)
|
||||||
if not file_path:
|
if not file_path:
|
||||||
continue
|
continue
|
||||||
|
|
@ -172,15 +189,12 @@ def get_lesson_names():
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
def _build_lessons_with_objectives(lesson_links):
|
||||||
def get_lessons_with_learning_objectives():
|
"""Build enriched lesson dicts (title, description, prerequisite_titles)
|
||||||
"""Get lessons with learning objectives extracted from LESSON_INFO sections."""
|
from parsed (link_text, filename) pairs, reading each lesson file."""
|
||||||
lessons = []
|
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)
|
file_path = find_lesson_file(filename)
|
||||||
if not file_path:
|
if not file_path:
|
||||||
continue
|
continue
|
||||||
|
|
@ -263,12 +277,29 @@ def get_lessons_with_learning_objectives():
|
||||||
return lessons
|
return lessons
|
||||||
|
|
||||||
|
|
||||||
def get_ordered_lessons_with_learning_objectives(progress=None):
|
@lru_cache(maxsize=32)
|
||||||
"""Get lessons ordered per home.md with completion status from progress dict."""
|
def get_lessons_with_learning_objectives():
|
||||||
|
"""Get lessons with learning objectives extracted from LESSON_INFO sections."""
|
||||||
home_content = _read_home_md()
|
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
|
# Build title -> slug mapping for prerequisite resolution
|
||||||
title_to_slug = {lesson['title']: lesson['filename'].replace('.md', '') for lesson in all_lessons}
|
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
|
return sub_home_path, folder_name
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
|
||||||
def get_sub_home_data(folder_name):
|
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 dict with keys: title, intro_html, lessons, folder, url.
|
||||||
Returns None if no sub-home.md found or unreadable.
|
Returns None if no sub-home.md found or unreadable.
|
||||||
|
|
@ -373,6 +403,15 @@ def get_sub_home_data(folder_name):
|
||||||
return None
|
return None
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
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)
|
content = _read_md_cached(sub_home_path)
|
||||||
if not content:
|
if not content:
|
||||||
return None
|
return None
|
||||||
|
|
@ -444,13 +483,18 @@ def get_sub_home_data(folder_name):
|
||||||
'prerequisite_titles': prerequisite_titles,
|
'prerequisite_titles': prerequisite_titles,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
data = {
|
||||||
'title': title,
|
'title': title,
|
||||||
'intro_html': intro_html,
|
'intro_html': intro_html,
|
||||||
'lessons': lessons,
|
'lessons': lessons,
|
||||||
'folder': folder_name,
|
'folder': folder_name,
|
||||||
'url': f'/bab/{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