feat: add Arduino lesson support, documentation, and load testing utilities with improved CLI feedback

master
a2nr 2026-04-11 08:39:57 +07:00
parent 314975ac65
commit 10548b1f51
13 changed files with 1663 additions and 1102 deletions

3
.gitignore vendored
View File

@ -1 +1,4 @@
__pycache__
test_data.json
load-test/env
load-test/test_data.json

116
README.md
View File

@ -174,6 +174,88 @@ Untuk materi yang melibatkan simulator rangkaian elektronika:
| `---EXPECTED_CIRCUIT_OUTPUT---` | Validasi rangkaian (format JSON) |
| `---KEY_TEXT_CIRCUIT---` | Kata kunci rangkaian |
#### Blok Khusus Arduino/Velxio
Untuk materi yang menggunakan simulator Arduino (Velxio):
| Blok | Fungsi |
|------|--------|
| `---INITIAL_CODE_ARDUINO---` | Kode Arduino awal di editor Velxio |
| `---VELXIO_CIRCUIT---` | Rangkaian komponen (JSON: board, components, wires) |
| `---EXPECTED_SERIAL_OUTPUT---` | Output serial yang diharapkan (subsequence match) |
| `---EXPECTED_WIRING---` | Wiring yang harus dibuat siswa (JSON, lenient) |
| `---KEY_TEXT---` | Kata kunci yang harus ada di kode siswa |
Contoh materi Arduino:
```markdown
---INITIAL_CODE_ARDUINO---
void setup() {
pinMode(13, OUTPUT);
Serial.begin(9600);
}
void loop() {
digitalWrite(13, HIGH);
Serial.println("LED ON");
delay(1000);
digitalWrite(13, LOW);
Serial.println("LED OFF");
delay(1000);
}
---END_INITIAL_CODE_ARDUINO---
---VELXIO_CIRCUIT---
{
"board": "arduino:avr:uno",
"components": [
{ "type": "wokwi-led", "id": "led-1", "x": 400, "y": -200, "props": { "color": "red", "pin": 13 } }
],
"wires": []
}
---END_VELXIO_CIRCUIT---
---EXPECTED_SERIAL_OUTPUT---
LED ON
LED OFF
---END_EXPECTED_SERIAL_OUTPUT---
---EXPECTED_WIRING---
{
"wires": [
{ "start": { "componentId": "arduino-uno", "pinName": "13" }, "end": { "componentId": "led-1", "pinName": "A" } },
{ "start": { "componentId": "led-1", "pinName": "C" }, "end": { "componentId": "arduino-uno", "pinName": "GND" } }
]
}
---END_EXPECTED_WIRING---
---KEY_TEXT---
pinMode
digitalWrite
---END_KEY_TEXT---
```
##### Referensi Nama Pin Komponen Velxio
| Komponen | Tipe Wokwi | Pin Names |
|----------|------------|----------|
| Arduino Uno | `wokwi-arduino-uno` | `0`-`13`, `A0`-`A5`, `GND`, `5V`, `3.3V` |
| LED | `wokwi-led` | `A` (Anode), `C` (Cathode) |
| Push Button | `wokwi-pushbutton` | `1.l`, `2.l`, `1.r`, `2.r` |
| Resistor | `wokwi-resistor` | `1`, `2` |
| RGB LED | `wokwi-rgb-led` | `R`, `G`, `B`, `COM` |
> **Penting:** Nama pin harus **persis** seperti tabel di atas.
> `componentId` untuk Arduino Uno selalu `arduino-uno` (huruf kecil, dengan strip).
##### Evaluasi Arduino
Sistem mengevaluasi 3 aspek (semua harus lulus):
1. **Key Text** — kata kunci wajib ada di source code siswa
2. **Serial Output** — baris yang diharapkan harus muncul dalam urutan (subsequence match)
3. **Wiring** — koneksi yang diharapkan harus ada (lenient: extra wires OK, GND.1/GND.2 dinormalisasi)
### 5. Generate Token Siswa
Setelah materi siap, generate file `tokens_siswa.csv`:
@ -244,15 +326,25 @@ Folder `examples/` berisi contoh lengkap yang digunakan oleh `./elemes.sh init`:
```
examples/
├── content/
│ ├── home.md # Halaman utama (3 materi)
│ ├── 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
│ ├── 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
└── tokens_siswa.csv # Data siswa contoh (1 guru + 3 siswa)
```
File `rangkaian_dasar.md` adalah contoh **materi hybrid** yang menggabungkan
latihan pemrograman C dan simulator rangkaian elektronika dalam satu lesson.
### Jenis Materi
| Tipe | Contoh | Evaluasi |
|------|--------|----------|
| **C/Python** | `hello_world.md`, `variabel.md` | Output stdout |
| **Hybrid** | `rangkaian_dasar.md` | C output + node voltage |
| **Arduino (Velxio)** | `led_blink_arduino.md` | Key text + serial + wiring |
| **Arduino (tanpa wiring)** | `hello_serial_arduino.md` | Key text + serial |
## FAQ
@ -276,6 +368,22 @@ akan otomatis muncul.
Tidak. Tailscale opsional untuk akses remote. Tanpa Tailscale, LMS
bisa diakses di jaringan lokal via `http://localhost:3000`.
**Q: Bagaimana membuat materi Arduino baru?**
Buat file `.md` baru di `content/` dengan blok `---INITIAL_CODE_ARDUINO---`
dan `---VELXIO_CIRCUIT---`. Lihat contoh di `led_blink_arduino.md`
atau `button_input_arduino.md`. Pastikan `componentId` dan `pinName`
di `EXPECTED_WIRING` sesuai dengan tabel referensi pin di atas.
**Q: Materi Arduino bisa tanpa wiring?**
Ya. Jika hanya ingin evaluasi kode + serial output, cukup sertakan
`VELXIO_CIRCUIT` dengan `components: []` kosong dan hilangkan blok
`EXPECTED_WIRING`. Contoh: `hello_serial_arduino.md`.
**Q: Apakah wiring harus persis sama?**
Tidak harus. Evaluasi bersifat *lenient*: koneksi yang diharapkan harus ada,
tapi siswa boleh menambahkan kabel ekstra. Pin GND juga dinormalisasi
(GND.1 = GND.2 = GND).
## Persyaratan Sistem
- [Podman](https://podman.io/) dan `podman-compose`

View File

@ -7,24 +7,24 @@ PROJECT_NAME="$(basename "$PARENT_DIR")"
case "$1" in
init)
echo "=== Elemes Quick Start ==="
echo "=== Elemes Quick Start ==="
echo ""
# .env
if [ -f "$PARENT_DIR/.env" ]; then
echo "[skip] .env sudah ada"
echo "✅ [Skip] .env sudah ada"
else
cp "$EXAMPLES_DIR/../.env.example" "$PARENT_DIR/.env"
echo "[buat] .env (edit sesuai kebutuhan)"
echo "📝 [Buat] .env (pastikan untuk edit sesuai kebutuhanmu)"
fi
# content/
if [ -d "$PARENT_DIR/content" ] && [ "$(ls -A "$PARENT_DIR/content" 2>/dev/null)" ]; then
echo "[skip] content/ sudah ada"
echo "✅ [Skip] Folder content/ sudah ada"
else
mkdir -p "$PARENT_DIR/content"
cp -n "$EXAMPLES_DIR/content/"*.md "$PARENT_DIR/content/"
echo "[buat] content/ ($(ls "$PARENT_DIR/content/"*.md 2>/dev/null | wc -l) materi)"
echo "📁 [Buat] Folder content/ ($(ls "$PARENT_DIR/content/"*.md 2>/dev/null | wc -l) materi contoh ditambahkan)"
fi
# assets/
@ -32,47 +32,84 @@ init)
# tokens
if [ -f "$PARENT_DIR/tokens_siswa.csv" ]; then
echo "[skip] tokens_siswa.csv sudah ada"
echo "✅ [Skip] tokens_siswa.csv sudah ada"
else
cp "$EXAMPLES_DIR/tokens_siswa.csv" "$PARENT_DIR/tokens_siswa.csv"
echo "[buat] tokens_siswa.csv (edit untuk tambah siswa)"
echo "👥 [Buat] tokens_siswa.csv (edit untuk menambah data siswa asli)"
fi
echo ""
echo "Selesai! Langkah selanjutnya:"
echo " 1. Edit ../.env sesuai kebutuhan"
echo " 2. Edit ../content/home.md untuk daftar materi"
echo " 3. Edit ../tokens_siswa.csv untuk data siswa"
echo " 4. Jalankan: ./elemes.sh runbuild"
echo "🎯 Selesai! Langkah selanjutnya yang direkomendasikan:"
echo " 👉 1. Edit file ../.env sesuai dengan kebutuhan environment-mu"
echo " 👉 2. Edit ../content/home.md untuk menyusun daftar materi"
echo " 👉 3. Edit ../tokens_siswa.csv untuk mendaftarkan akun siswa"
echo " 🚀 4. Jalankan: ./elemes.sh runbuild"
echo ""
;;
stop | run | runbuild | runclearbuild)
echo "Stop Container..."
echo "🛑 Menghentikan container yang sedang berjalan..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env down
;;&
stop) ;;
stop)
echo "✅ Container berhasil dihentikan."
;;
runclearbuild)
echo "Cleanup dangling images..."
echo "🧹 Membersihkan container dan image (prune)..."
podman image prune -f
echo "Build Container (no cache)..."
echo "🏗️ Membangun ulang container dari awal (no-cache)..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env build --no-cache
;;&
runbuild)
echo "Build Container..."
echo "🏗️ Membangun container..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env build
;;&
runbuild | runclearbuild)
echo "Run Container..."
echo "🚀 Menjalankan container di background..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env up --force-recreate -d
echo "✅ Elemes berhasil dijalankan!"
;;
run)
echo "Run Container..."
echo "🚀 Menjalankan container..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env up -d
echo "✅ Elemes berhasil dijalankan!"
;;
generatetoken)
echo "Generating tokens_siswa.csv from content..."
echo "🔄 Melakukan sinkronisasi tokens_siswa.csv dari materi yang ada..."
python3 "$SCRIPT_DIR/generate_tokens.py"
;;
loadtest)
echo "📊 === Elemes Load Testing ==="
cd "$SCRIPT_DIR/load-test" || exit
if [ ! -d "env" ]; then
echo "⚙️ Membuat Python Virtual Environment (env/)..."
python3 -m venv env
fi
echo "⚙️ Mengaktifkan environment & menginstall requirements..."
source env/bin/activate
pip install -r requirements.txt > /dev/null 2>&1
echo "⚙️ Mempersiapkan Test Data & menginjeksi akun Bot..."
python3 content_parser.py --num-tokens 50
echo ""
echo "🚀 Memulai Locust Test Suite..."
echo "👉 Buka http://localhost:8089 di browser web milikmu."
echo "👉 Masukkan URL backend Elemes sebagai Host (contoh: http://localhost:5000)"
echo "👉 Tekan CTRL+C di terminal ini untuk menghentikan test."
echo ""
locust -f locustfile.py
;;
*)
echo "elemes.sh ( init | run | runbuild | runclearbuild | stop | generatetoken )"
echo "💡 Cara Penggunaan elemes.sh:"
echo " ./elemes.sh init # Inisialisasi konfigurasi, folder, & data tokens"
echo " ./elemes.sh run # Menjalankan container LMS yang sudah ada"
echo " ./elemes.sh runbuild # Build image lalu jalankan container"
echo " ./elemes.sh runclearbuild # Bersihkan cache, Re-build total, lalu jalankan"
echo " ./elemes.sh stop # Menghentikan container yang sedang berjalan"
echo " ./elemes.sh generatetoken # Sinkronisasi kolom tokens CSV sesuai file markdown"
echo " ./elemes.sh loadtest # Menjalankan utilitas simulasi Load Test (Locust)"
;;
esac

View File

@ -0,0 +1,206 @@
---LESSON_INFO---
Pelajaran Arduino: Membaca input tombol dan mengontrol LED.
**Learning Objectives:**
- Memahami fungsi `digitalRead()` untuk membaca input digital
- Menggunakan `INPUT_PULLUP` untuk push button
- Menerapkan logika kondisional (`if-else`) berdasarkan input
- Menghubungkan push button dan LED ke Arduino
**Prerequisites:**
- Hello, World!
- LED Blink
- Hello Serial
---END_LESSON_INFO---
# Button Input — Membaca Tombol
Setelah belajar **output** (menyalakan LED) dan **serial** (mengirim teks),
sekarang kita belajar **input** — membaca tombol yang ditekan oleh pengguna.
## Konsep Digital Input
Setiap pin digital Arduino bisa dikonfigurasi sebagai **INPUT** atau **OUTPUT**:
```
pinMode(13, OUTPUT); // Pin 13 = output (LED)
pinMode(2, INPUT_PULLUP); // Pin 2 = input dengan pullup
```
### Apa itu INPUT_PULLUP?
Tanpa resistor, pin input Arduino bisa membaca nilai **acak** (mengambang).
`INPUT_PULLUP` mengaktifkan resistor internal Arduino yang menarik pin ke **HIGH** (5V).
```
Tanpa pullup: Pin ──?── (nilai acak: HIGH atau LOW)
Dengan pullup: Pin ──R──► 5V (default HIGH, jadi LOW saat ditekan)
```
Saat tombol **tidak ditekan**: `digitalRead()` = `HIGH` (1)
Saat tombol **ditekan**: `digitalRead()` = `LOW` (0)
> **Perhatikan:** Logikanya terbalik! Tombol ditekan = LOW, tidak ditekan = HIGH.
## Fungsi digitalRead()
```
int nilai = digitalRead(2); // Baca pin 2
if (nilai == LOW) {
// Tombol sedang ditekan
}
```
## Rangkaian
Hubungkan push button dan LED ke Arduino:
```
Pin 2 ──► Button pin 1.l (input)
Button pin 2.l ──► GND (ground)
Pin 13 ──► LED Anode (A)
LED Cathode (C) ──► GND
```
Push button menggunakan `INPUT_PULLUP`, jadi hanya perlu 2 kabel: dari pin Arduino ke tombol, dan dari tombol ke GND. Tidak perlu resistor eksternal!
## Contoh Program
```
const int BUTTON_PIN = 2;
const int LED_PIN = 13;
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
Serial.begin(9600);
}
void loop() {
int state = digitalRead(BUTTON_PIN);
if (state == LOW) {
digitalWrite(LED_PIN, HIGH);
Serial.println("PRESSED");
} else {
digitalWrite(LED_PIN, LOW);
Serial.println("RELEASED");
}
delay(100);
}
```
---EXERCISE---
### Tantangan
**Kode Arduino:**
Tulis program yang membaca tombol di **pin 2** dan mengontrol LED di **pin 13**:
- Saat tombol ditekan → LED menyala, cetak `PRESSED` ke Serial Monitor
- Saat tombol dilepas → LED mati, cetak `RELEASED` ke Serial Monitor
Gunakan `INPUT_PULLUP` agar tidak perlu resistor tambahan untuk tombol.
**Rangkaian:**
Hubungkan komponen berikut di simulator:
- Pin 2 Arduino → Button (pin 1.l)
- Button (pin 2.l) → GND Arduino
- Pin 13 Arduino → LED Anode (A)
- LED Cathode (C) → GND Arduino
Setelah selesai, tekan **Compile & Run**, lalu **klik tombol** di simulator untuk menguji.
---
---INITIAL_CODE_ARDUINO---
// Button Input - Membaca Tombol
// Tekan tombol untuk menyalakan LED
const int BUTTON_PIN = 2;
const int LED_PIN = 13;
void setup() {
// Setup pin mode
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
Serial.begin(9600);
}
void loop() {
int buttonState = digitalRead(BUTTON_PIN);
if (buttonState == LOW) {
// Tombol ditekan
digitalWrite(LED_PIN, HIGH);
Serial.println("PRESSED");
} else {
// Tombol dilepas
digitalWrite(LED_PIN, LOW);
Serial.println("RELEASED");
}
delay(100);
}
---END_INITIAL_CODE_ARDUINO---
---VELXIO_CIRCUIT---
{
"board": "arduino:avr:uno",
"components": [
{
"type": "wokwi-pushbutton",
"id": "button-1",
"x": 400,
"y": -100,
"rotation": 0,
"props": {}
},
{
"type": "wokwi-led",
"id": "led-1",
"x": 400,
"y": -250,
"rotation": 0,
"props": {
"color": "green",
"pin": 13
}
}
],
"wires": []
}
---END_VELXIO_CIRCUIT---
---EXPECTED_SERIAL_OUTPUT---
PRESSED
---END_EXPECTED_SERIAL_OUTPUT---
---EXPECTED_WIRING---
{
"wires": [
{
"start": { "componentId": "arduino-uno", "pinName": "2" },
"end": { "componentId": "button-1", "pinName": "1.l" }
},
{
"start": { "componentId": "button-1", "pinName": "2.l" },
"end": { "componentId": "arduino-uno", "pinName": "GND" }
},
{
"start": { "componentId": "arduino-uno", "pinName": "13" },
"end": { "componentId": "led-1", "pinName": "A" }
},
{
"start": { "componentId": "led-1", "pinName": "C" },
"end": { "componentId": "arduino-uno", "pinName": "GND" }
}
]
}
---END_EXPECTED_WIRING---
---KEY_TEXT---
digitalRead
INPUT_PULLUP
digitalWrite
Serial
---END_KEY_TEXT---

View File

@ -0,0 +1,138 @@
---LESSON_INFO---
Pelajaran Arduino: Komunikasi Serial Monitor dan debugging.
**Learning Objectives:**
- Memahami fungsi `Serial.begin()` dan baud rate
- Menggunakan `Serial.println()` dan `Serial.print()` untuk mengirim data
- Membedakan `println` (dengan newline) dan `print` (tanpa newline)
- Menggunakan `millis()` untuk tracking waktu
**Prerequisites:**
- Hello, World!
---END_LESSON_INFO---
# Hello Serial — Komunikasi Serial Arduino
Serial Monitor adalah **alat debugging utama** saat bekerja dengan Arduino.
Dengan Serial Monitor, kamu bisa mengirim teks dari Arduino ke komputer
untuk memantau apa yang sedang terjadi dalam program.
## Apa itu Komunikasi Serial?
Arduino berkomunikasi dengan komputer melalui **port serial** (USB).
Data dikirim bit-per-bit dengan kecepatan tertentu yang disebut **baud rate**.
```
Arduino ──USB──► Komputer (Serial Monitor)
TX/RX
```
## Fungsi Serial Dasar
### 1. `Serial.begin(baud_rate)`
Memulai komunikasi serial. Harus dipanggil di `setup()`:
```
Serial.begin(9600); // Baud rate 9600 (paling umum)
```
### 2. `Serial.println(data)`
Mengirim data + **newline** (`\n`) di akhir:
```
Serial.println("Hello"); // output: Hello↵
Serial.println("World"); // output: World↵
```
### 3. `Serial.print(data)`
Mengirim data **tanpa newline**:
```
Serial.print("Suhu: "); // output: Suhu:
Serial.println("25 C"); // output: 25 C↵
```
Hasil gabungan: `Suhu: 25 C`
### 4. `millis()`
Mengembalikan jumlah milidetik sejak Arduino dinyalakan:
```
unsigned long waktu = millis();
Serial.print("Waktu: ");
Serial.print(waktu / 1000);
Serial.println(" detik");
```
## Contoh Program
```
void setup() {
Serial.begin(9600);
Serial.println("Arduino Ready!");
Serial.println("Serial Monitor aktif");
}
void loop() {
Serial.print("Uptime: ");
Serial.print(millis() / 1000);
Serial.println(" detik");
delay(2000);
}
```
---EXERCISE---
### Tantangan
**Tulis program Arduino** yang melakukan hal berikut saat dinyalakan (`setup`):
1. Mulai komunikasi serial dengan baud rate `9600`
2. Cetak teks `Hello Arduino` (dengan newline)
3. Cetak teks `Serial Ready` (dengan newline)
Kemudian di `loop()`:
4. Cetak `Tick` setiap 1 detik
Setelah selesai menulis kode, tekan **Compile & Run** dan tunggu beberapa detik agar Serial output muncul.
> **Tips:** Lesson ini tidak memerlukan wiring — cukup tulis kode saja!
---
---INITIAL_CODE_ARDUINO---
// Hello Serial - Belajar Serial Monitor
// Kirim pesan ke Serial Monitor
void setup() {
// 1. Mulai serial dengan baud rate 9600
// 2. Cetak "Hello Arduino"
// 3. Cetak "Serial Ready"
}
void loop() {
// 4. Cetak "Tick" setiap 1 detik
}
---END_INITIAL_CODE_ARDUINO---
---VELXIO_CIRCUIT---
{
"board": "arduino:avr:uno",
"components": [],
"wires": []
}
---END_VELXIO_CIRCUIT---
---EXPECTED_SERIAL_OUTPUT---
Hello Arduino
Serial Ready
Tick
---END_EXPECTED_SERIAL_OUTPUT---
---KEY_TEXT---
Serial.begin
Serial.println
delay
---END_KEY_TEXT---

View File

@ -21,6 +21,9 @@ Semua materi bisa dikerjakan langsung di browser!
### Arduino (Velxio)
4. [LED Blink](lesson/led_blink_arduino.md)
5. [Hello Serial](lesson/hello_serial_arduino.md)
6. [Button Input](lesson/button_input_arduino.md)
7. [Traffic Light](lesson/traffic_light_arduino.md)
----Available_Lessons----
@ -28,3 +31,6 @@ Semua materi bisa dikerjakan langsung di browser!
2. [Variabel](lesson/variabel.md)
3. [Rangkaian Dasar](lesson/rangkaian_dasar.md)
4. [LED Blink](lesson/led_blink_arduino.md)
5. [Hello Serial](lesson/hello_serial_arduino.md)
6. [Button Input](lesson/button_input_arduino.md)
7. [Traffic Light](lesson/traffic_light_arduino.md)

View File

@ -0,0 +1,231 @@
---LESSON_INFO---
Pelajaran Arduino: Membuat simulasi lampu lalu lintas dengan 3 LED.
**Learning Objectives:**
- Mengontrol beberapa LED secara berurutan
- Menerapkan pola sequence/timing dengan `delay()`
- Merangkai 3 LED ke Arduino
- Menggunakan `const int` untuk nama pin yang mudah dibaca
**Prerequisites:**
- Hello, World!
- LED Blink
- Hello Serial
- Button Input
---END_LESSON_INFO---
# Traffic Light — Lampu Lalu Lintas
Proyek ini mensimulasikan **lampu lalu lintas** menggunakan 3 LED:
merah, kuning, dan hijau. Kita akan mengontrol urutan nyala-mati
LED persis seperti lampu lalu lintas sungguhan.
## Urutan Lampu Lalu Lintas
```
1. MERAH menyala (3 detik) → Berhenti
2. KUNING menyala (1 detik) → Bersiap
3. HIJAU menyala (3 detik) → Jalan
4. KUNING menyala (1 detik) → Bersiap berhenti
5. Kembali ke MERAH...
```
## Menggunakan Konstanta untuk Pin
Daripada mengingat nomor pin, gunakan **konstanta bernama**:
```
const int RED_PIN = 13;
const int YELLOW_PIN = 12;
const int GREEN_PIN = 11;
```
Keuntungan:
- Kode lebih mudah dibaca: `digitalWrite(RED_PIN, HIGH)` vs `digitalWrite(13, HIGH)`
- Jika ingin ganti pin, cukup ubah di satu tempat
## Mengontrol Beberapa LED
Setiap LED memerlukan pin output sendiri:
```
void setup() {
pinMode(RED_PIN, OUTPUT);
pinMode(YELLOW_PIN, OUTPUT);
pinMode(GREEN_PIN, OUTPUT);
}
```
Pastikan hanya **satu LED menyala** pada satu waktu:
```
// Nyalakan merah, matikan yang lain
digitalWrite(RED_PIN, HIGH);
digitalWrite(YELLOW_PIN, LOW);
digitalWrite(GREEN_PIN, LOW);
```
## Serial Monitor untuk Debugging
Cetak status lampu ke Serial Monitor agar mudah di-debug:
```
Serial.println("RED");
delay(3000); // Merah menyala 3 detik
```
---EXERCISE---
### Tantangan
**Kode Arduino:**
Buat program yang mensimulasikan lampu lalu lintas:
1. **Merah** di pin 13 menyala selama 3 detik, cetak `RED` ke Serial
2. **Kuning** di pin 12 menyala selama 1 detik, cetak `YELLOW` ke Serial
3. **Hijau** di pin 11 menyala selama 3 detik, cetak `GREEN` ke Serial
4. **Kuning** lagi selama 1 detik, cetak `YELLOW` ke Serial
5. Ulangi dari merah
Pastikan hanya satu LED yang menyala pada satu waktu!
**Rangkaian:**
Hubungkan 3 LED ke Arduino:
- Pin 13 → LED Merah (Anode A), Cathode C → GND
- Pin 12 → LED Kuning (Anode A), Cathode C → GND
- Pin 11 → LED Hijau (Anode A), Cathode C → GND
Setelah selesai, tekan **Compile & Run** dan perhatikan LED menyala bergantian.
---
---INITIAL_CODE_ARDUINO---
// Traffic Light - Lampu Lalu Lintas
// Simulasi lampu merah, kuning, hijau
const int RED_PIN = 13;
const int YELLOW_PIN = 12;
const int GREEN_PIN = 11;
void setup() {
pinMode(RED_PIN, OUTPUT);
pinMode(YELLOW_PIN, OUTPUT);
pinMode(GREEN_PIN, OUTPUT);
Serial.begin(9600);
}
void loop() {
// Lampu MERAH
digitalWrite(RED_PIN, HIGH);
digitalWrite(YELLOW_PIN, LOW);
digitalWrite(GREEN_PIN, LOW);
Serial.println("RED");
delay(3000);
// Lampu KUNING
digitalWrite(RED_PIN, LOW);
digitalWrite(YELLOW_PIN, HIGH);
digitalWrite(GREEN_PIN, LOW);
Serial.println("YELLOW");
delay(1000);
// Lampu HIJAU
digitalWrite(RED_PIN, LOW);
digitalWrite(YELLOW_PIN, LOW);
digitalWrite(GREEN_PIN, HIGH);
Serial.println("GREEN");
delay(3000);
// Lampu KUNING lagi
digitalWrite(RED_PIN, LOW);
digitalWrite(YELLOW_PIN, HIGH);
digitalWrite(GREEN_PIN, LOW);
Serial.println("YELLOW");
delay(1000);
}
---END_INITIAL_CODE_ARDUINO---
---VELXIO_CIRCUIT---
{
"board": "arduino:avr:uno",
"components": [
{
"type": "wokwi-led",
"id": "led-red",
"x": 370,
"y": -300,
"rotation": 0,
"props": {
"color": "red",
"pin": 13
}
},
{
"type": "wokwi-led",
"id": "led-yellow",
"x": 370,
"y": -200,
"rotation": 0,
"props": {
"color": "yellow",
"pin": 12
}
},
{
"type": "wokwi-led",
"id": "led-green",
"x": 370,
"y": -100,
"rotation": 0,
"props": {
"color": "green",
"pin": 11
}
}
],
"wires": []
}
---END_VELXIO_CIRCUIT---
---EXPECTED_SERIAL_OUTPUT---
RED
YELLOW
GREEN
YELLOW
---END_EXPECTED_SERIAL_OUTPUT---
---EXPECTED_WIRING---
{
"wires": [
{
"start": { "componentId": "arduino-uno", "pinName": "13" },
"end": { "componentId": "led-red", "pinName": "A" }
},
{
"start": { "componentId": "led-red", "pinName": "C" },
"end": { "componentId": "arduino-uno", "pinName": "GND" }
},
{
"start": { "componentId": "arduino-uno", "pinName": "12" },
"end": { "componentId": "led-yellow", "pinName": "A" }
},
{
"start": { "componentId": "led-yellow", "pinName": "C" },
"end": { "componentId": "arduino-uno", "pinName": "GND" }
},
{
"start": { "componentId": "arduino-uno", "pinName": "11" },
"end": { "componentId": "led-green", "pinName": "A" }
},
{
"start": { "componentId": "led-green", "pinName": "C" },
"end": { "componentId": "arduino-uno", "pinName": "GND" }
}
]
}
---END_EXPECTED_WIRING---
---KEY_TEXT---
pinMode
digitalWrite
const int
Serial
delay
---END_KEY_TEXT---

57
load-test/README.md Normal file
View File

@ -0,0 +1,57 @@
# Elemes Load Test
Load test E2E menggunakan [Locust](https://locust.io/) yang otomatis di-generate dari folder `content/`.
## Cara Pakai
```bash
cd elemes/load-test
# 0. Virtual Environment
python3 -m venv ./env
source ./env/bin/activate
# 1. Install dependency
pip install -r requirements.txt
# 2. Generate test data dari content/
python content_parser.py
# 3. Jalankan Locust (opsional: set VELXIO_HOST jika Velxio bukan di localhost:8001)
export VELXIO_HOST=http://localhost:8001
locust -f locustfile.py
```
Buka **http://localhost:8089**, masukkan URL backend Elemes (misalnya `http://localhost:5000`), lalu mulai test.
## File
| File | Fungsi |
|------|--------|
| `content_parser.py` | Parse `content/*.md``test_data.json` + inject token test ke CSV |
| `locustfile.py` | Locust script (7 task weighted) yang baca `test_data.json` |
| `test_data.json` | Auto-generated, **jangan di-commit** |
| `requirements.txt` | Dependency (`locust`) |
## Test Scenarios (8 Tasks)
| # | Task | Weight | Target | Deskripsi |
|---|------|--------|--------|----------|
| 1 | Browse Lessons | 3 | Elemes | GET `/lessons`, validasi jumlah lesson |
| 2 | View Detail | 5 | Elemes | GET `/lesson/{slug}.json`, validasi field per tipe |
| 3 | Compile C | 4 | Elemes | POST `/compile`, validasi output vs expected |
| 4 | Compile Python | 3 | Elemes | POST `/compile`, validasi output vs expected |
| 5 | Verify Arduino | 2 | Elemes | GET lesson Arduino, validasi JSON structure |
| 6 | Complete Flow | 2 | Both | fetch → compile → track-progress |
| 7 | Progress Report | 1 | Elemes | Login guru → GET `/progress-report.json` |
| 8 | **Compile Arduino** | **3** | **Velxio** | POST `/api/compile`, validasi hex_content |
## Re-generate Setelah Tambah Lesson Baru
Setiap kali ada lesson baru di `content/`, cukup jalankan ulang:
```bash
python content_parser.py
```
`test_data.json` akan di-update otomatis dan Locust langsung test lesson baru.

306
load-test/content_parser.py Normal file
View File

@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""
Content Parser for Locust E2E Test Generation.
Scans the content/ directory, parses lesson markdown files,
extracts test-relevant data, and writes test_data.json.
Also injects a LOCUST_TEST token into tokens_siswa.csv if not present.
Usage (from elemes/load-test/):
python content_parser.py
python content_parser.py --content-dir ../../content --tokens-file ../../tokens_siswa.csv --num-tokens 50
"""
import argparse
import csv
import json
import os
import re
import sys
import uuid
# ---------------------------------------------------------------------------
# Marker extraction (mirrors lesson_service.py logic)
# ---------------------------------------------------------------------------
def extract_section(content: str, start_marker: str, end_marker: str) -> tuple[str, str]:
"""Extract text between markers. Returns (extracted, remaining)."""
if start_marker not in content or end_marker not in content:
return "", content
start_idx = content.find(start_marker)
end_idx = content.find(end_marker)
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
return "", content
extracted = content[start_idx + len(start_marker):end_idx].strip()
remaining = content[:start_idx] + content[end_idx + len(end_marker):]
return extracted, remaining
def detect_lesson_type(content: str) -> str:
"""Detect lesson type from markers present in content."""
has_arduino = '---INITIAL_CODE_ARDUINO---' in content
has_c = '---INITIAL_CODE---' in content
has_python = '---INITIAL_PYTHON---' in content
has_circuit = '---INITIAL_CIRCUIT---' in content
has_quiz = '---INITIAL_QUIZ---' in content
has_velxio = '---VELXIO_CIRCUIT---' in content
if has_arduino or (has_velxio and not has_c and not has_python):
return 'arduino'
if has_quiz:
return 'quiz'
if has_c and has_circuit:
return 'hybrid'
if has_circuit and not has_c and not has_python:
return 'circuit'
if has_python and not has_c:
return 'python'
return 'c'
def parse_lesson(filepath: str) -> dict:
"""Parse a single lesson markdown file and extract test data."""
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
slug = os.path.basename(filepath).replace('.md', '')
lesson_type = detect_lesson_type(content)
# Extract all relevant sections
initial_code_c, _ = extract_section(content, '---INITIAL_CODE---', '---END_INITIAL_CODE---')
initial_python, _ = extract_section(content, '---INITIAL_PYTHON---', '---END_INITIAL_PYTHON---')
initial_code_arduino, _ = extract_section(content, '---INITIAL_CODE_ARDUINO---', '---END_INITIAL_CODE_ARDUINO---')
velxio_circuit, _ = extract_section(content, '---VELXIO_CIRCUIT---', '---END_VELXIO_CIRCUIT---')
expected_output, _ = extract_section(content, '---EXPECTED_OUTPUT---', '---END_EXPECTED_OUTPUT---')
expected_output_python, _ = extract_section(content, '---EXPECTED_OUTPUT_PYTHON---', '---END_EXPECTED_OUTPUT_PYTHON---')
expected_serial, _ = extract_section(content, '---EXPECTED_SERIAL_OUTPUT---', '---END_EXPECTED_SERIAL_OUTPUT---')
expected_wiring, _ = extract_section(content, '---EXPECTED_WIRING---', '---END_EXPECTED_WIRING---')
key_text, _ = extract_section(content, '---KEY_TEXT---', '---END_KEY_TEXT---')
solution_code, _ = extract_section(content, '---SOLUTION_CODE---', '---END_SOLUTION_CODE---')
solution_python, _ = extract_section(content, '---SOLUTION_PYTHON---', '---END_SOLUTION_PYTHON---')
# A lesson is compilable if it has solution code or Arduino initial code
is_compilable = bool(solution_code or solution_python or initial_code_arduino)
data = {
'slug': slug,
'type': lesson_type,
'has_c': bool(initial_code_c),
'has_python': bool(initial_python),
'has_circuit': '---INITIAL_CIRCUIT---' in content,
'has_arduino': bool(initial_code_arduino),
'has_velxio': bool(velxio_circuit),
'compilable': is_compilable,
'key_text': key_text,
}
# Add type-specific fields
if initial_code_c:
data['initial_code_c'] = initial_code_c
if solution_code:
data['solution_code'] = solution_code
if initial_python:
data['initial_python'] = initial_python
if solution_python:
data['solution_python'] = solution_python
if expected_output:
data['expected_output'] = expected_output
if expected_output_python:
data['expected_output_python'] = expected_output_python
if initial_code_arduino:
data['initial_code_arduino'] = initial_code_arduino
if velxio_circuit:
data['velxio_circuit'] = velxio_circuit
if expected_serial:
data['expected_serial'] = expected_serial
if expected_wiring:
data['expected_wiring'] = expected_wiring
return data
def get_ordered_slugs(content_dir: str) -> list[str]:
"""Get lesson slugs in order from home.md's Available_Lessons section."""
home_path = os.path.join(content_dir, 'home.md')
if not os.path.exists(home_path):
return []
with open(home_path, 'r', encoding='utf-8') as f:
home_content = f.read()
parts = home_content.split('----Available_Lessons----')
if len(parts) <= 1:
# Try alternate separator
parts = home_content.split('---Available_Lessons---')
if len(parts) <= 1:
return []
links = re.findall(r'\[([^\]]+)\]\((?:lesson/)?([^\)]+)\)', parts[1])
return [fn.replace('.md', '') for _, fn in links]
# ---------------------------------------------------------------------------
# Token injection
# ---------------------------------------------------------------------------
def ensure_test_tokens(tokens_file: str, lesson_slugs: list[str], num_tokens: int) -> list[str]:
"""
Ensure a specified number of test tokens exist in tokens_siswa.csv.
Returns a list of token strings.
"""
existing_tokens = []
rows = []
fieldnames = []
if os.path.exists(tokens_file):
with open(tokens_file, 'r', newline='', encoding='utf-8') as f:
reader = csv.DictReader(f, delimiter=';')
fieldnames = reader.fieldnames or []
rows = list(reader)
for row in rows:
if row.get('token', '').startswith('LOCUST_TEST_'):
existing_tokens.append(row['token'])
if not fieldnames:
fieldnames = ['token', 'nama_siswa'] + lesson_slugs
tokens_to_return = existing_tokens[:num_tokens]
if len(tokens_to_return) < num_tokens:
needed = num_tokens - len(tokens_to_return)
new_tokens = []
for i in range(needed):
token = f"LOCUST_TEST_{uuid.uuid4().hex[:8]}"
new_tokens.append(token)
new_row = {'token': token, 'nama_siswa': f"Locust Bot {len(tokens_to_return) + i + 1}"}
for slug in lesson_slugs:
if slug not in fieldnames:
fieldnames.append(slug)
new_row[slug] = 'not_started'
rows.append(new_row)
with open(tokens_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=';')
writer.writeheader()
writer.writerows(rows)
print(f" ✚ Injected {len(new_tokens)} new test tokens.")
tokens_to_return.extend(new_tokens)
else:
print(f" ♻ Reusing {num_tokens} existing test tokens.")
return tokens_to_return
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def _get_teacher_token(tokens_file: str) -> str:
"""Get the teacher token (first data row)."""
if not os.path.exists(tokens_file):
return ""
with open(tokens_file, 'r', newline='', encoding='utf-8') as f:
reader = csv.DictReader(f, delimiter=';')
for row in reader:
return row.get('token', '')
return ""
def main():
parser = argparse.ArgumentParser(description='Parse content/ for Locust test generation')
parser.add_argument('--content-dir', default='../../content',
help='Path to content directory (default: ../../content)')
parser.add_argument('--tokens-file', default='../../tokens_siswa.csv',
help='Path to tokens CSV (default: ../../tokens_siswa.csv)')
parser.add_argument('--num-tokens', type=int, default=50,
help='Number of test tokens to generate (default: 50)')
parser.add_argument('--output', default='test_data.json',
help='Output JSON file (default: test_data.json)')
args = parser.parse_args()
content_dir = os.path.abspath(args.content_dir)
tokens_file = os.path.abspath(args.tokens_file)
print(f"\n{'='*60}")
print(f" Elemes Content Parser for Locust E2E Testing")
print(f"{'='*60}")
print(f" Content dir : {content_dir}")
print(f" Tokens file : {tokens_file}")
print(f" Num tokens : {args.num_tokens}")
print(f" Output : {args.output}")
print()
# 1. Get ordered lesson slugs
ordered_slugs = get_ordered_slugs(content_dir)
if not ordered_slugs:
# Fallback: scan directory
ordered_slugs = [
f.replace('.md', '')
for f in sorted(os.listdir(content_dir))
if f.endswith('.md') and f != 'home.md'
]
print(f" 📚 Found {len(ordered_slugs)} lessons:")
# 2. Parse each lesson
lessons = []
for slug in ordered_slugs:
filepath = os.path.join(content_dir, f'{slug}.md')
if not os.path.exists(filepath):
print(f"{slug}.md not found, skipping")
continue
lesson = parse_lesson(filepath)
lessons.append(lesson)
# Summary icon per type
icons = {
'c': '🔧', 'python': '🐍', 'hybrid': '🔀',
'circuit': '', 'arduino': '🤖', 'quiz': ''
}
icon = icons.get(lesson['type'], '📄')
compilable = '✓ compile' if lesson.get('compilable') else '✗ compile'
print(f" {icon} {slug} [{lesson['type']}] {compilable}")
# 3. Inject test tokens
print()
tokens = ensure_test_tokens(tokens_file, ordered_slugs, args.num_tokens)
# 4. Build output
test_data = {
'generated_by': 'content_parser.py',
'tokens': tokens,
'teacher_token': _get_teacher_token(tokens_file),
'lessons': lessons,
'stats': {
'total': len(lessons),
'c': sum(1 for l in lessons if l['type'] == 'c'),
'python': sum(1 for l in lessons if l['type'] == 'python'),
'hybrid': sum(1 for l in lessons if l['type'] == 'hybrid'),
'circuit': sum(1 for l in lessons if l['type'] == 'circuit'),
'arduino': sum(1 for l in lessons if l['type'] == 'arduino'),
'quiz': sum(1 for l in lessons if l['type'] == 'quiz'),
'compilable': sum(1 for l in lessons if l.get('compilable')),
}
}
# 5. Write output
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(test_data, f, indent=2, ensure_ascii=False)
print(f"\n ✅ Wrote {args.output}")
print(f" {test_data['stats']['total']} lessons "
f"({test_data['stats']['compilable']} compilable)")
print(f"\n Next: locust -f locustfile.py")
print(f"{'='*60}\n")
if __name__ == '__main__':
main()

482
load-test/locustfile.py Normal file
View File

@ -0,0 +1,482 @@
"""
Locust E2E Test Suite for Elemes LMS.
Auto-generated test scenarios from content/ folder via content_parser.py.
Reads test_data.json to know which lessons exist and their types.
Usage (from elemes/load-test/):
1. python content_parser.py # generate test_data.json
2. locust -f locustfile.py # open web UI at http://localhost:8089
Set host to your Elemes backend URL in the web UI
Environment variables:
VELXIO_HOST Velxio backend URL for Arduino compilation
(default: http://localhost:8001)
All API paths hit the Flask backend directly (no /api prefix needed
when targeting Flask at :5000). Arduino compile hits Velxio backend.
"""
import json
import logging
import os
import random
import time
import requests
from locust import HttpUser, task, between, events
# ---------------------------------------------------------------------------
# Load test data
# ---------------------------------------------------------------------------
TEST_DATA_FILE = os.path.join(os.path.dirname(__file__), 'test_data.json')
def load_test_data() -> dict:
"""Load test_data.json generated by content_parser.py."""
if not os.path.exists(TEST_DATA_FILE):
raise FileNotFoundError(
f"{TEST_DATA_FILE} not found. "
f"Run 'python content_parser.py' first."
)
with open(TEST_DATA_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
try:
TEST_DATA = load_test_data()
except FileNotFoundError as e:
logging.error(str(e))
TEST_DATA = {'token': '', 'teacher_token': '', 'lessons': []}
TOKENS = TEST_DATA.get('tokens', [])
TEACHER_TOKEN = TEST_DATA.get('teacher_token', '')
ALL_LESSONS = TEST_DATA.get('lessons', [])
# Velxio backend URL for Arduino compilation
VELXIO_HOST = os.environ.get('VELXIO_HOST', 'http://localhost:8001')
# Pre-filter lessons by type for task assignment
C_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'c' and l.get('solution_code')]
PYTHON_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'python' and l.get('solution_python')]
HYBRID_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'hybrid']
ARDUINO_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'arduino']
COMPILABLE_LESSONS = C_LESSONS + PYTHON_LESSONS + [
l for l in HYBRID_LESSONS if l.get('solution_code') or l.get('solution_python')
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def check_key_text(code: str, key_text: str) -> bool:
"""Client-side key_text check (mirrors exercise.ts logic)."""
if not key_text.strip():
return True
keys = [k.strip() for k in key_text.split('\n') if k.strip()]
return all(k in code for k in keys)
# ---------------------------------------------------------------------------
# Locust User
# ---------------------------------------------------------------------------
class ElemesStudent(HttpUser):
"""
Simulates a student interacting with the Elemes LMS.
Each user logs in once (on_start), then performs weighted tasks
that mirror real student behavior.
"""
wait_time = between(1, 5)
def on_start(self):
"""Login once when user spawns."""
self.token = random.choice(TOKENS) if TOKENS else ''
resp = self.client.post(
'/login',
json={'token': self.token},
name='/login'
)
data = resp.json()
if not data.get('success'):
logging.warning(f"Login failed: {data.get('message')}")
# ── Task 1: Browse Lessons (weight=3) ──────────────────────────────
@task(3)
def browse_lessons(self):
"""Fetch lesson list — simulates landing on home page."""
with self.client.get('/lessons', name='/lessons', catch_response=True) as resp:
data = resp.json()
lessons = data.get('lessons', [])
if len(lessons) == 0:
resp.failure("No lessons returned")
elif len(lessons) != len(ALL_LESSONS):
resp.failure(
f"Expected {len(ALL_LESSONS)} lessons, got {len(lessons)}"
)
# ── Task 2: View Lesson Detail (weight=5) ──────────────────────────
@task(5)
def view_lesson_detail(self):
"""Fetch a random lesson's detail JSON."""
if not ALL_LESSONS:
return
lesson = random.choice(ALL_LESSONS)
slug = lesson['slug']
with self.client.get(
f'/lesson/{slug}.json',
name='/lesson/[slug].json',
catch_response=True
) as resp:
data = resp.json()
if 'error' in data:
resp.failure(f"Lesson {slug} not found")
return
# Validate fields based on type
if lesson['type'] == 'arduino':
if not data.get('initial_code_arduino'):
resp.failure(f"{slug}: missing initial_code_arduino")
if not data.get('velxio_circuit'):
resp.failure(f"{slug}: missing velxio_circuit")
# Validate velxio_circuit is valid JSON
try:
json.loads(data['velxio_circuit'])
except (json.JSONDecodeError, TypeError):
resp.failure(f"{slug}: velxio_circuit is not valid JSON")
elif lesson['type'] in ('c', 'hybrid'):
if not data.get('initial_code'):
resp.failure(f"{slug}: missing initial_code")
elif lesson['type'] == 'python':
if not data.get('initial_python') and not data.get('initial_code'):
resp.failure(f"{slug}: missing initial code")
# ── Task 3: Compile C Lesson (weight=4) ────────────────────────────
@task(4)
def compile_c_lesson(self):
"""Compile a C lesson's solution and validate output."""
if not C_LESSONS:
return
lesson = random.choice(C_LESSONS)
code = lesson.get('solution_code', lesson.get('initial_code_c', ''))
expected = lesson.get('expected_output', '')
if not code:
return
with self.client.post(
'/compile',
json={'code': code, 'language': 'c'},
name='/compile [C]',
catch_response=True
) as resp:
data = resp.json()
if not data.get('success'):
resp.failure(f"Compile failed: {data.get('error', 'unknown')}")
return
output = data.get('output', '')
# Validate expected output
if expected and expected.strip() not in output:
resp.failure(
f"{lesson['slug']}: expected '{expected.strip()[:50]}' "
f"not in output '{output[:50]}'"
)
return
# Validate key_text
key_text = lesson.get('key_text', '')
if key_text and not check_key_text(code, key_text):
resp.failure(f"{lesson['slug']}: key_text check failed")
# ── Task 4: Compile Python Lesson (weight=3) ───────────────────────
@task(3)
def compile_python_lesson(self):
"""Compile a Python lesson's solution and validate output."""
if not PYTHON_LESSONS:
return
lesson = random.choice(PYTHON_LESSONS)
code = lesson.get('solution_python', lesson.get('initial_python', ''))
expected = lesson.get('expected_output_python', lesson.get('expected_output', ''))
if not code:
return
with self.client.post(
'/compile',
json={'code': code, 'language': 'python'},
name='/compile [Python]',
catch_response=True
) as resp:
data = resp.json()
if not data.get('success'):
resp.failure(f"Compile failed: {data.get('error', 'unknown')}")
return
output = data.get('output', '')
if expected and expected.strip() not in output:
resp.failure(
f"{lesson['slug']}: expected '{expected.strip()[:50]}' "
f"not in output '{output[:50]}'"
)
# ── Task 5: Verify Arduino Lesson Structure (weight=2) ─────────────
@task(2)
def verify_arduino_lesson(self):
"""
Verify an Arduino lesson's data structure.
Validates JSON integrity of velxio_circuit and expected_wiring.
"""
if not ARDUINO_LESSONS:
return
lesson = random.choice(ARDUINO_LESSONS)
slug = lesson['slug']
with self.client.get(
f'/lesson/{slug}.json',
name='/lesson/[arduino].json',
catch_response=True
) as resp:
data = resp.json()
errors = []
# Check required fields
if not data.get('initial_code_arduino'):
errors.append('missing initial_code_arduino')
vc = data.get('velxio_circuit', '')
if vc:
try:
circuit = json.loads(vc)
if 'board' not in circuit:
errors.append('velxio_circuit missing board field')
except (json.JSONDecodeError, TypeError):
errors.append('velxio_circuit invalid JSON')
else:
errors.append('missing velxio_circuit')
# Check expected_wiring JSON validity if present
ew = data.get('expected_wiring', '')
if ew:
try:
wiring = json.loads(ew)
if 'wires' not in wiring:
errors.append('expected_wiring missing wires array')
except (json.JSONDecodeError, TypeError):
errors.append('expected_wiring invalid JSON')
# Key text check against initial code
key_text = lesson.get('key_text', '')
initial_code = data.get('initial_code_arduino', '')
if key_text and initial_code and not check_key_text(initial_code, key_text):
errors.append('key_text not found in initial_code_arduino')
if errors:
resp.failure(f"{slug}: {'; '.join(errors)}")
# ── Task 8: Compile Arduino via Velxio (weight=3) ──────────────────
@task(3)
def compile_arduino_lesson(self):
"""
Compile an Arduino lesson's code via Velxio backend.
Hits Velxio's POST /api/compile endpoint.
Validates: compile success + hex_content returned.
Note: serial output can only be validated in-browser (avr8js sim).
"""
if not ARDUINO_LESSONS:
return
lesson = random.choice(ARDUINO_LESSONS)
code = lesson.get('initial_code_arduino', '')
if not code:
return
try:
resp = requests.post(
f'{VELXIO_HOST}/api/compile',
json={'code': code, 'board_fqbn': 'arduino:avr:uno'},
timeout=30
)
data = resp.json()
if not data.get('success'):
# Report as Locust failure for stats tracking
self.environment.events.request.fire(
request_type='POST',
name='/api/compile [Arduino]',
response_time=resp.elapsed.total_seconds() * 1000,
response_length=len(resp.content),
exception=Exception(
f"{lesson['slug']}: compile failed — "
f"{data.get('error', data.get('stderr', 'unknown'))}"
),
)
return
hex_content = data.get('hex_content', '')
if not hex_content:
self.environment.events.request.fire(
request_type='POST',
name='/api/compile [Arduino]',
response_time=resp.elapsed.total_seconds() * 1000,
response_length=len(resp.content),
exception=Exception(f"{lesson['slug']}: no hex_content"),
)
return
# Success
self.environment.events.request.fire(
request_type='POST',
name='/api/compile [Arduino]',
response_time=resp.elapsed.total_seconds() * 1000,
response_length=len(resp.content),
exception=None,
)
except requests.RequestException as e:
self.environment.events.request.fire(
request_type='POST',
name='/api/compile [Arduino]',
response_time=0,
response_length=0,
exception=e,
)
# ── Task 6: Complete Lesson Flow (weight=2) ────────────────────────
@task(2)
def complete_lesson_flow(self):
"""
Full flow: fetch lesson compile (if possible) track progress.
Simulates a student completing a lesson.
"""
if not ALL_LESSONS:
return
lesson = random.choice(ALL_LESSONS)
slug = lesson['slug']
# 1. Fetch lesson detail
resp = self.client.get(
f'/lesson/{slug}.json',
name='/lesson/[slug].json (flow)'
)
if resp.status_code != 200:
return
# 2. Compile if it's a C or Python lesson with solution
if lesson['type'] in ('c', 'hybrid') and lesson.get('solution_code'):
self.client.post(
'/compile',
json={'code': lesson['solution_code'], 'language': 'c'},
name='/compile (flow)'
)
time.sleep(0.5) # Simulate student reading output
elif lesson['type'] == 'python' and lesson.get('solution_python'):
self.client.post(
'/compile',
json={'code': lesson['solution_python'], 'language': 'python'},
name='/compile (flow)'
)
time.sleep(0.5)
elif lesson['type'] == 'arduino' and lesson.get('initial_code_arduino'):
try:
requests.post(
f'{VELXIO_HOST}/api/compile',
json={'code': lesson['initial_code_arduino'],
'board_fqbn': 'arduino:avr:uno'},
timeout=30
)
except requests.RequestException:
pass # Non-critical in flow
time.sleep(0.5)
# 3. Track progress
with self.client.post(
'/track-progress',
json={
'token': self.token,
'lesson_name': slug,
'status': 'completed'
},
name='/track-progress',
catch_response=True
) as resp:
data = resp.json()
if not data.get('success'):
resp.failure(f"Track progress failed: {data.get('message')}")
# ── Task 7: Progress Report (weight=1) ─────────────────────────────
@task(1)
def view_progress_report(self):
"""Fetch progress report (teacher perspective)."""
if not TEACHER_TOKEN:
return
# Login as teacher
self.client.post(
'/login',
json={'token': TEACHER_TOKEN},
name='/login [teacher]'
)
with self.client.get(
'/progress-report.json',
name='/progress-report.json',
catch_response=True
) as resp:
data = resp.json()
students = data.get('students', [])
lessons = data.get('lessons', [])
if not lessons:
resp.failure("No lessons in progress report")
# Re-login as student for subsequent tasks
self.client.post(
'/login',
json={'token': self.token},
name='/login [re-auth]'
)
# ---------------------------------------------------------------------------
# Event hooks
# ---------------------------------------------------------------------------
@events.test_start.add_listener
def on_test_start(environment, **kwargs):
"""Log test configuration at start."""
stats = TEST_DATA.get('stats', {})
logging.info("=" * 60)
logging.info(" Elemes LMS — Locust E2E Test")
logging.info("=" * 60)
logging.info(f" Lessons: {stats.get('total', 0)} total")
logging.info(f" C: {stats.get('c', 0)}, Python: {stats.get('python', 0)}, "
f"Hybrid: {stats.get('hybrid', 0)}, Arduino: {stats.get('arduino', 0)}")
logging.info(f" Compilable: {stats.get('compilable', 0)}")
logging.info(f" Tokens: {len(TOKENS)} loaded")
logging.info(f" Velxio: {VELXIO_HOST}")
logging.info("=" * 60)

View File

@ -0,0 +1 @@
locust

View File

@ -73,5 +73,4 @@ volumes:
networks:
main_network:
drive: bridge
network_mode: service:elemes-ts
driver: bridge

File diff suppressed because it is too large Load Diff