feat: improve evaluation system and update circuit editor to use localStorage for persistence

This commit is contained in:
a2nr 2026-04-10 14:00:35 +07:00
parent 74a8d87853
commit 997ab78f56
16 changed files with 1752 additions and 503 deletions

View File

@ -43,6 +43,8 @@ Penjelasan:
- Tidak perlu `\n``print()` otomatis menambahkan baris baru
- Tidak perlu `return 0` atau titik koma
---EXERCISE---
### Latihan
Buat program yang mencetak teks berikut:
@ -50,14 +52,16 @@ Buat program yang mencetak teks berikut:
```
Halo Dunia
```
---
---INITIAL_CODE---
#include <stdio.h>
int main() {
// Tulis kode kamu di sini
printf("Halo Dunia\\n");
return 0;
}
---END_INITIAL_CODE---
@ -66,11 +70,29 @@ int main() {
Halo Dunia
---END_EXPECTED_OUTPUT---
---EXPECTED_OUTPUT_PYTHON---
Halo Dunia
---END_EXPECTED_OUTPUT_PYTHON---
---INITIAL_PYTHON---
# Tulis kode kamu di sini
print("Halo Dunia")
---END_INITIAL_PYTHON---
---SOLUTION_CODE---
#include <stdio.h>
int main() {
printf("Halo Dunia\\n");
return 0;
}
---END_SOLUTION_CODE---
---SOLUTION_PYTHON---
print("Halo Dunia")
---END_SOLUTION_PYTHON---
---KEY_TEXT---
printf
print
---END_KEY_TEXT---

View File

@ -19,8 +19,12 @@ Semua materi bisa dikerjakan langsung di browser!
### Elektronika (Hybrid)
3. [Rangkaian Dasar](lesson/rangkaian_dasar.md)
### Arduino (Velxio)
4. [LED Blink](lesson/led_blink_arduino.md)
----Available_Lessons----
1. [Hello, World!](lesson/hello_world.md)
2. [Variabel](lesson/variabel.md)
3. [Rangkaian Dasar](lesson/rangkaian_dasar.md)
4. [LED Blink](lesson/led_blink_arduino.md)

View File

@ -0,0 +1,147 @@
---LESSON_INFO---
Pelajaran Arduino: Mengedipkan LED menggunakan simulator Velxio.
**Learning Objectives:**
- Memahami fungsi `pinMode()`, `digitalWrite()`, dan `delay()`
- Menghubungkan LED dan resistor ke Arduino Uno
- Menggunakan `Serial.print()` untuk debugging
**Prerequisites:**
- Hello, World!
---END_LESSON_INFO---
# LED Blink dengan Arduino
Proyek pertama setiap programmer Arduino: **mengedipkan LED!**
## Konsep Dasar
### Digital Output
Arduino Uno memiliki 14 pin digital (013). Setiap pin bisa menjadi **OUTPUT** atau **INPUT**:
```
pinMode(13, OUTPUT); // Set pin 13 sebagai output
digitalWrite(13, HIGH); // Nyalakan (5V)
digitalWrite(13, LOW); // Matikan (0V)
```
### Rangkaian LED
LED membutuhkan **resistor pembatas arus** agar tidak rusak:
```
Pin 13 → Resistor 220Ω → LED (Anode) → LED (Cathode) → GND
```
Tanpa resistor, arus terlalu besar dan LED bisa terbakar!
### Serial Monitor
`Serial.println()` mengirim teks ke Serial Monitor — sangat berguna untuk debugging:
```
Serial.begin(9600); // Mulai komunikasi serial
Serial.println("LED ON"); // Cetak teks + newline
```
---EXERCISE---
### Tantangan
**Kode Arduino:**
Tulis program yang mengedipkan LED di **pin 13** dengan interval 1 detik.
Program harus mencetak `LED ON` saat LED menyala dan `LED OFF` saat LED mati ke Serial Monitor.
**Rangkaian:**
Hubungkan komponen-komponen berikut:
- Pin 13 Arduino → Resistor (pin 1)
- Resistor (pin 2) → LED Anode (A)
- LED Cathode (C) → GND Arduino
Setelah selesai, tekan **Compile & Run** untuk menjalankan program dan tunggu beberapa detik agar Serial output muncul.
---
---INITIAL_CODE_ARDUINO---
// LED Blink - Tugas Pertama Arduino
// Mengedipkan LED di pin 13 dengan resistor pembatas arus
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": "led",
"id": "led-builtin",
"x": 394,
"y": -208,
"rotation": 0,
"props": {
"color": "red",
"pin": 13,
"state": true,
"value": true
}
},
{
"type": "resistor",
"id": "resistor-1775728124959-5rknpronw",
"x": 292,
"y": -159,
"rotation": 0,
"props": {
"value": true,
"state": true
}
}
],
"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": "resistor-1775728124959-5rknpronw", "pinName": "1" }
},
{
"start": { "componentId": "resistor-1775728124959-5rknpronw", "pinName": "2" },
"end": { "componentId": "led-builtin", "pinName": "A" }
},
{
"start": { "componentId": "arduino-uno", "pinName": "GND" },
"end": { "componentId": "led-builtin", "pinName": "C" }
}
]
}
---END_EXPECTED_WIRING---
---KEY_TEXT---
pinMode
digitalWrite
Serial
---END_KEY_TEXT---

View File

@ -73,17 +73,31 @@ Lengkapi rangkaian agar tegangan di **Vout** bernilai **2.5V**.
#include <stdio.h>
int main() {
// Hitung voltage divider: Vout = Vin * R2 / (R1 + R2)
// Vin=5, R1=1000, R2=1000
float vin = 5.0;
float r1 = 1000.0;
float r2 = 1000.0;
float vout = vin * r2 / (r1 + r2);
printf("Vout = %.2fV\n", vout);
return 0;
}
---END_INITIAL_CODE---
---INITIAL_PYTHON---
# Hitung voltage divider: Vout = Vin * R2 / (R1 + R2)
# Vin=5, R1=1000, R2=1000
vin = 5
r1 = 1000
r2 = 1000
vout = vin * (r2 / (r1 + r2))
print(f"Vout = {vout:.2f}V")
---END_INITIAL_PYTHON---
---INITIAL_CIRCUIT---
<cir f="1" ts="0.000005" ic="10.20027730826997" cb="50" pb="50" vr="5" mts="5e-11">
<v x="80 200 80 112" f="0" wf="0" maxv="5"/>
<r x="80 112 176 112" f="0" r="1000"/>
<r x="176 112 176 200" f="0" r="1000"/>
<w x="176 200 80 200" f="0"/>
<ln x="176 112 208 32" f="0" te="Vout"/>
</cir>
@ -101,14 +115,42 @@ Vout = 2.50V
}
---END_EXPECTED_CIRCUIT_OUTPUT---
---INITIAL_PYTHON---
# Hitung voltage divider: Vout = Vin * R2 / (R1 + R2)
# Vin=5, R1=1000, R2=1000
---SOLUTION_CODE---
#include <stdio.h>
---END_INITIAL_PYTHON---
int main() {
float vin = 5.0;
float r1 = 1000.0;
float r2 = 1000.0;
float vout = vin * r2 / (r1 + r2);
printf("Vout = %.2fV\n", vout);
return 0;
}
---END_SOLUTION_CODE---
---SOLUTION_PYTHON---
vin = 5
r1 = 1000
r2 = 1000
vout = vin * (r2 / (r1 + r2))
print(f"Vout = {vout:.2f}V")
---END_SOLUTION_PYTHON---
---SOLUTION_CIRCUIT---
<cir f="1" ts="0.000005" ic="10.20027730826997" cb="50" pb="50" vr="5" mts="5e-11">
<v x="80 200 80 112" f="0" wf="0" maxv="5"/>
<r x="80 112 176 112" f="0" r="1000"/>
<r x="176 112 176 200" f="0" r="1000"/>
<w x="176 200 80 200" f="0"/>
<ln x="176 112 208 32" f="0" te="Vout"/>
</cir>
---END_SOLUTION_CIRCUIT---
---KEY_TEXT---
printf
float
print(f"
---END_KEY_TEXT---
---KEY_TEXT_CIRCUIT---

View File

@ -78,8 +78,8 @@ Panjang nama: 10
#include <stdio.h>
int main() {
// Deklarasikan variabel nama_panjang bertipe int
// Cetak hasilnya menggunakan printf
int nama_panjang = 10;
printf("Panjang nama: %d\n", nama_panjang);
return 0;
}
@ -93,9 +93,30 @@ Panjang nama: 10
# Deklarasikan variabel nama_panjang
# Cetak hasilnya menggunakan print
nama_panjang = 10
print(f"Panjang nama: {nama_panjang}")
---END_INITIAL_PYTHON---
---SOLUTION_CODE---
#include <stdio.h>
int main() {
int nama_panjang = 10;
printf("Panjang nama: %d\n", nama_panjang);
return 0;
}
---END_SOLUTION_CODE---
---SOLUTION_PYTHON---
nama_panjang = 10
print(f"Panjang nama: {nama_panjang}")
---END_SOLUTION_PYTHON---
---KEY_TEXT---
int
printf
print(f"
{
}
---END_KEY_TEXT---

View File

@ -1,5 +1,3 @@
token;nama_siswa;hello_world;variabel;rangkaian_dasar
GURU001;Pak Budi;not_started;not_started;not_started
SISWA001;Andi Pratama;not_started;not_started;not_started
SISWA002;Siti Nurhaliza;not_started;not_started;not_started
SISWA003;Rizky Firmansyah;not_started;not_started;not_started
token;nama_siswa;hello_world;variabel;rangkaian_dasar;led_blink_arduino
121312;Anggoro Dwi;completed;completed;completed;completed
dummy_token_12345;Example Student;completed;not_started;not_started;not_started

1 token nama_siswa hello_world variabel rangkaian_dasar led_blink_arduino
2 GURU001 121312 Pak Budi Anggoro Dwi not_started completed not_started completed not_started completed completed
3 SISWA001 dummy_token_12345 Andi Pratama Example Student not_started completed not_started not_started not_started
SISWA002 Siti Nurhaliza not_started not_started not_started
SISWA003 Rizky Firmansyah not_started not_started not_started

View File

@ -23,7 +23,7 @@
saving = true;
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
sessionStorage.setItem(storageKey, text);
localStorage.setItem(storageKey, text);
saving = false;
}, 1000);
}
@ -54,7 +54,7 @@
// Load initial circuit or draft
let toLoad = initialCircuit;
if (storageKey) {
const saved = sessionStorage.getItem(storageKey);
const saved = localStorage.getItem(storageKey);
if (saved) {
toLoad = saved;
}
@ -69,19 +69,6 @@
}
if (onready) onready(simApi);
// Setup auto-save polling
if (storageKey) {
autoSaveInterval = setInterval(() => {
if (simApi && ready) {
const currentText = simApi.exportCircuit();
const saved = sessionStorage.getItem(storageKey);
if (currentText && currentText !== saved && currentText.trim().length > 10) {
saveToStorage(currentText);
}
}
}, 5000);
}
} else {
console.error("CircuitEditor: Could not find CircuitJS1 API object");
}
@ -109,7 +96,39 @@
$effect(() => {
if (simApi && ready && initialCircuit && initialCircuit !== lastLoadedCircuit) {
lastLoadedCircuit = initialCircuit;
loadCircuitToSim(initialCircuit);
const saved = storageKey ? localStorage.getItem(storageKey) : null;
if (!saved) {
loadCircuitToSim(initialCircuit);
}
}
});
// Load draft reactively when storageKey is set (fixes delayed auth problem)
$effect(() => {
if (storageKey && storageKey !== lastStorageKey) {
lastStorageKey = storageKey;
if (!ready || !simApi) return;
const saved = localStorage.getItem(storageKey);
if (saved) {
loadCircuitToSim(saved);
} else if (initialCircuit) {
loadCircuitToSim(initialCircuit);
}
}
});
// Reactively poll to sync auto-save, ensures it gets setup even if storageKey is delayed
$effect(() => {
if (simApi && ready && storageKey) {
const interval = setInterval(() => {
const currentText = simApi.exportCircuit();
const saved = localStorage.getItem(storageKey);
if (currentText && currentText !== saved && currentText.trim().length > 10) {
saveToStorage(currentText);
}
}, 5000);
return () => clearInterval(interval);
}
});

View File

@ -26,11 +26,13 @@
let saveTimeout: any;
function saveToStorage(value: string) {
if (!storageKey) return;
if (!lastStorageKey && !storageKey) return;
const activeKey = lastStorageKey || storageKey;
if (!activeKey) return;
saving = true;
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
sessionStorage.setItem(storageKey, value);
localStorage.setItem(activeKey, value);
saving = false;
}, 1000);
}
@ -89,7 +91,11 @@
if (update.docChanged) {
const val = update.state.doc.toString();
onchange?.(val);
saveToStorage(val);
// Use lastStorageKey ref or dynamic check since storageKey prop might be stale in closure
if (lastStorageKey) {
saveToStorage(val);
}
}
})
);
@ -184,7 +190,7 @@
let initialCode = code;
if (storageKey) {
const saved = sessionStorage.getItem(storageKey);
const saved = localStorage.getItem(storageKey);
if (saved !== null) {
initialCode = saved;
}
@ -267,7 +273,7 @@
lastStorageKey = storageKey;
if (!ready || !view || !storageKey) return;
const saved = sessionStorage.getItem(storageKey);
const saved = localStorage.getItem(storageKey);
if (saved !== null) {
setCode(saved);
} else {

View File

@ -4,6 +4,7 @@
error?: string;
loading?: boolean;
success?: boolean | null;
debug?: string[];
}
export interface OutputEntry {
@ -21,6 +22,8 @@
let { sections = [] }: Props = $props();
let showDebug = $state<Record<string, boolean>>({});
let anyLoading = $derived(sections.some(s => s.data.loading));
let overallSuccess = $derived.by(() => {
@ -55,8 +58,16 @@
{:else if sec.data.success === false}
<span class="section-badge error">Error</span>
{/if}
{#if sec.data.debug && sec.data.debug.length > 0}
<label class="debug-toggle">
<input type="checkbox" bind:checked={showDebug[sec.key]} /> Debug
</label>
{/if}
</div>
<pre class="output-body">{#if sec.data.loading}{sec.loadingText}{:else if sec.data.error}{sec.data.error}{:else if sec.data.output}{sec.data.output}{:else}<span class="placeholder">{sec.placeholder}</span>{/if}</pre>
<pre class="output-body">{#if sec.data.loading}{sec.loadingText}{:else if sec.data.error}{sec.data.error}{:else if sec.data.output}{sec.data.output}{:else}<span class="placeholder">{sec.placeholder}</span>{/if}{#if sec.data.debug && showDebug[sec.key]}
── Debug ──
{sec.data.debug.join('\n')}{/if}</pre>
</div>
{/each}
@ -153,4 +164,13 @@
color: var(--color-text-muted);
font-style: italic;
}
.debug-toggle {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
font-weight: normal;
cursor: pointer;
}
</style>

View File

@ -25,7 +25,7 @@ interface VelxioWire {
interface EvaluationExpected {
key_text?: string;
serial_output?: string;
wiring?: [string, string][];
wiring?: [string, string][] | { wires: Array<{ start: { componentId: string; pinName: string }; end: { componentId: string; pinName: string } }> };
}
export interface EvaluationResult {
@ -34,6 +34,7 @@ export interface EvaluationResult {
serial?: boolean;
wiring?: boolean;
messages: string[];
debug?: string[];
}
// ---------------------------------------------------------------------------
@ -145,12 +146,25 @@ export class VelxioBridge {
const edges = studentWires.map(w =>
`${w.start.componentId}:${w.start.pinName}${w.end.componentId}:${w.end.pinName}`
);
result.wiring = this.matchWiring(studentWires, expected.wiring);
// Extract expected wires array from both formats
let expectedWires: any[] = [];
if (Array.isArray(expected.wiring)) {
expectedWires = expected.wiring;
} else if (expected.wiring.wires && Array.isArray(expected.wiring.wires)) {
expectedWires = expected.wiring.wires;
}
result.wiring = this.matchWiring(studentWires, expectedWires);
result.messages.push(result.wiring
? '✅ Rangkaian wiring benar'
: '❌ Wiring belum sesuai. Periksa kembali koneksi komponen');
dbg.push(`[DBG wiring] ${studentWires.length} wires: ${edges.join(' | ')}`);
dbg.push(`[DBG wiring] expected: ${expected.wiring.map(p => p.join('↔')).join(' | ')}`);
dbg.push(`[DBG wiring] expected: ${expectedWires.map((w: any) => {
if (Array.isArray(w) && w.length === 2) return w.join('↔');
if (w.start && w.end) return `${w.start.componentId}:${w.start.pinName}${w.end.componentId}:${w.end.pinName}`;
return JSON.stringify(w);
}).join(' | ')}`);
dbg.push(`[DBG wiring] → ${result.wiring}`);
} else {
result.wiring = false;
@ -163,8 +177,8 @@ export class VelxioBridge {
const checks = [result.key_text, result.serial, result.wiring].filter(v => v !== undefined);
result.pass = checks.length > 0 && checks.every(Boolean);
// Append debug info to messages so it's visible in the output panel
result.messages.push('', '── Debug ──', ...dbg);
// Store debug info separately
result.debug = dbg;
return result;
}
@ -204,34 +218,188 @@ export class VelxioBridge {
/** Subsequence match: expected lines must appear in order within actual. */
private matchSerial(actual: string, expected: string): boolean {
const actualLines = actual.trim().split('\n').map(l => l.trim());
const expectedLines = expected.trim().split('\n').map(l => l.trim());
let j = 0;
for (const line of actualLines) {
if (j < expectedLines.length && line === expectedLines[j]) j++;
if (j === expectedLines.length) return true;
if (!expected.trim()) return true;
if (!actual.trim()) return false;
const actualLines = actual.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const expectedLines = expected.split('\n').map(l => l.trim()).filter(l => l.length > 0);
let expectedIdx = 0;
for (const actualLine of actualLines) {
if (expectedIdx < expectedLines.length) {
// Check if expected line is a substring of actual line (case-insensitive)
if (actualLine.toLowerCase().includes(expectedLines[expectedIdx].toLowerCase())) {
expectedIdx++;
}
}
if (expectedIdx === expectedLines.length) return true;
}
return j === expectedLines.length;
return expectedIdx === expectedLines.length;
}
/** Lenient wiring check: all expected edges must exist in student wires. */
private matchWiring(studentWires: VelxioWire[], expectedPairs: [string, string][]): boolean {
/**
* Net-based wiring check: groups pins into electrical nets using
* Union-Find (Disjoint Set Union), then compares net composition.
*
* This is robust against:
* - Node order swaps (AB vs BA)
* - Transitive connections (A-B-C vs A-C, B in middle)
* - Extra student wires (lenient - only missing connections fail)
*/
private matchWiring(studentWires: VelxioWire[], expectedPairs: [string, string][] | any[]): boolean {
// Normalize power pin names (e.g., GND.2 → GND, VCC.1 → VCC)
const normalizePin = (pin: string) => pin.replace(/^(GND|VCC|5V|3V3|3\.3V)\.\d+$/i, '$1');
const norm = (a: string, b: string) => {
const normA = a.replace(/:(.+)$/, (_, pin) => ':' + normalizePin(pin));
const normB = b.replace(/:(.+)$/, (_, pin) => ':' + normalizePin(pin));
return [normA, normB].sort().join('↔');
const normalizePin = (pin: string) => {
return pin.replace(/^(GND|VCC|5V|3V3|3\.3V|POWER)\.\d+$/i, '$1');
};
const studentEdges = new Set(
studentWires.map(w =>
norm(
`${w.start.componentId}:${w.start.pinName}`,
`${w.end.componentId}:${w.end.pinName}`
)
)
);
return expectedPairs.every(([a, b]) => studentEdges.has(norm(a, b)));
const normPin = (pin: string) => {
const [comp, name] = pin.includes(':') ? pin.split(':') : ['', pin];
return `${comp}:${normalizePin(name)}`;
};
// Union-Find (DSU) implementation
const parent = new Map<string, string>();
const rank = new Map<string, string>();
const find = (x: string): string => {
if (!parent.has(x)) {
parent.set(x, x);
rank.set(x, x);
return x;
}
// Path compression
if (parent.get(x) !== x) {
parent.set(x, find(parent.get(x)!));
}
return parent.get(x)!;
};
const union = (a: string, b: string) => {
const rootA = find(a);
const rootB = find(b);
if (rootA === rootB) return;
// Union by rank
const rankA = rank.get(rootA) || '';
const rankB = rank.get(rootB) || '';
if (rankA < rankB) {
parent.set(rootA, rootB);
} else if (rankA > rankB) {
parent.set(rootB, rootA);
} else {
parent.set(rootB, rootA);
rank.set(rootA, rankA + '1');
}
};
// Build student nets
for (const wire of studentWires) {
const start = normPin(`${wire.start.componentId}:${wire.start.pinName}`);
const end = normPin(`${wire.end.componentId}:${wire.end.pinName}`);
union(start, end);
}
// Collect all pins and group into nets for student
const allStudentPins = new Set<string>();
for (const wire of studentWires) {
allStudentPins.add(normPin(`${wire.start.componentId}:${wire.start.pinName}`));
allStudentPins.add(normPin(`${wire.end.componentId}:${wire.end.pinName}`));
}
const studentNets = new Map<string, Set<string>>();
for (const pin of allStudentPins) {
const root = find(pin);
if (!studentNets.has(root)) {
studentNets.set(root, new Set());
}
studentNets.get(root)!.add(pin);
}
// Build expected nets
const expectedWires: Array<{ start: string; end: string }> = [];
for (const expected of expectedPairs) {
if (Array.isArray(expected) && expected.length === 2) {
expectedWires.push({ start: normPin(expected[0]), end: normPin(expected[1]) });
} else if (expected.start && expected.end) {
expectedWires.push({
start: normPin(`${expected.start.componentId}:${expected.start.pinName}`),
end: normPin(`${expected.end.componentId}:${expected.end.pinName}`)
});
}
}
const expectedParent = new Map<string, string>();
const expectedRank = new Map<string, string>();
const expectedFind = (x: string): string => {
if (!expectedParent.has(x)) {
expectedParent.set(x, x);
expectedRank.set(x, x);
return x;
}
if (expectedParent.get(x) !== x) {
expectedParent.set(x, expectedFind(expectedParent.get(x)!));
}
return expectedParent.get(x)!;
};
const expectedUnion = (a: string, b: string) => {
const rootA = expectedFind(a);
const rootB = expectedFind(b);
if (rootA === rootB) return;
const rankA = expectedRank.get(rootA) || '';
const rankB = expectedRank.get(rootB) || '';
if (rankA < rankB) {
expectedParent.set(rootA, rootB);
} else if (rankA > rankB) {
expectedParent.set(rootB, rootA);
} else {
expectedParent.set(rootB, rootA);
expectedRank.set(rootA, rankA + '1');
}
};
for (const wire of expectedWires) {
expectedUnion(wire.start, wire.end);
}
const allExpectedPins = new Set<string>();
for (const wire of expectedWires) {
allExpectedPins.add(wire.start);
allExpectedPins.add(wire.end);
}
const expectedNets = new Map<string, Set<string>>();
for (const pin of allExpectedPins) {
const root = expectedFind(pin);
if (!expectedNets.has(root)) {
expectedNets.set(root, new Set());
}
expectedNets.get(root)!.add(pin);
}
// Compare: every expected net must have a matching student net
// that contains ALL pins from the expected net
for (const [, expectedNet] of expectedNets) {
let found = false;
for (const [, studentNet] of studentNets) {
// Check if student net contains all pins from expected net
let allPresent = true;
for (const pin of expectedNet) {
if (!studentNet.has(pin)) {
allPresent = false;
break;
}
}
if (allPresent) {
found = true;
break;
}
}
if (!found) {
return false;
}
}
return true;
}
}

View File

@ -22,6 +22,8 @@ export interface LessonContent {
expected_serial_output: string;
expected_wiring: string;
solution_code: string;
solution_circuit: string;
solution_python: string;
key_text: string;
lesson_title: string;
lesson_completed: boolean;

View File

@ -16,7 +16,7 @@
import { createFloatingPanel } from '$actions/floatingPanel.svelte';
import { highlightAllCode } from '$actions/highlightCode';
import { renderCircuitEmbeds } from '$actions/renderCircuitEmbeds';
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import type { LessonContent } from '$types/lesson';
// Data from +page.ts load function (SSR + client)
@ -35,7 +35,7 @@
let pythonCode = $state('');
// Output state per language + circuit
const freshOutput = () => ({ output: '', error: '', loading: false, success: null as boolean | null });
const freshOutput = () => ({ output: '', error: '', loading: false, success: null as boolean | null, debug: undefined as string[] | undefined });
let cOut = $state(freshOutput());
let pyOut = $state(freshOutput());
let circuitOut = $state(freshOutput());
@ -43,8 +43,8 @@
// Helper: get the active code output object for current language
function getCodeOut() { return currentLanguage === 'python' ? pyOut : cOut; }
// AND-logic: track whether each exercise type has passed (persists across runs)
let codePassed = $state(false);
let cPassed = $state(false);
let pythonPassed = $state(false);
let circuitPassed = $state(false);
// Velxio (Arduino simulator) state
@ -79,7 +79,7 @@
secs.push({ key: 'circuit', label: 'Circuit', icon: '\u26A1', data: circuitOut, placeholder: 'Klik "Cek Rangkaian" untuk mengevaluasi', loadingText: 'Mengevaluasi rangkaian...' });
}
if (tabs.includes('velxio')) {
secs.push({ key: 'velxio', label: 'Arduino', icon: '\u{1F4DF}', data: velxioOut, placeholder: 'Klik "Submit" untuk mengevaluasi', loadingText: 'Mengevaluasi...' });
secs.push({ key: 'velxio', label: 'Arduino', icon: '\u{1F4DF}', data: velxioOut, placeholder: 'Klik "Compile & Run" untuk menjalankan kode', loadingText: 'Mengevaluasi...' });
}
return secs;
});
@ -123,9 +123,11 @@
// Sync lesson data when navigating between lessons
$effect(() => {
if (lesson) {
data = lesson;
lessonCompleted = lesson.lesson_completed;
const currentLesson = lesson; // capture dependency
if (currentLesson) {
untrack(() => {
data = currentLesson;
lessonCompleted = currentLesson.lesson_completed;
// Initialize per-language code
cCode = lesson.initial_code_c || '';
@ -143,7 +145,8 @@
pyOut = freshOutput();
circuitOut = freshOutput();
velxioOut = freshOutput();
codePassed = false;
cPassed = false;
pythonPassed = false;
circuitPassed = false;
showSolution = false;
@ -164,7 +167,8 @@
title: lesson.lesson_title,
completed: lesson.lesson_completed,
prevLesson: lesson.prev_lesson,
nextLesson: lesson.next_lesson
nextLesson: currentLesson.next_lesson
});
});
}
});
@ -217,10 +221,19 @@
}
}
/** For hybrid lessons, check if all exercise types have passed (AND logic). */
/** Check if all exercise types for this lesson have passed (AND logic). */
function checkAllPassed(): boolean {
if (!isHybrid) return true; // not hybrid, each evaluator handles itself
return codePassed && circuitPassed;
const needsC = data?.active_tabs?.includes('c');
const needsPython = data?.active_tabs?.includes('python');
const needsCircuit = data?.active_tabs?.includes('circuit');
if (!data?.active_tabs?.length) return true;
if (needsC && !cPassed) return false;
if (needsPython && !pythonPassed) return false;
if (needsCircuit && !circuitPassed) return false;
return true;
}
async function evaluateCircuit() {
@ -271,7 +284,7 @@
circuitPassed = true;
if (isHybrid) {
circuitOut.output += '\n✅ Rangkaian benar!';
if (!codePassed) circuitOut.output += '\n⏳ Selesaikan juga tantangan kode untuk menyelesaikan pelajaran ini.';
if (!checkAllPassed()) circuitOut.output += '\n⏳ Selesaikan juga tantangan kode untuk menyelesaikan pelajaran ini.';
}
if (checkAllPassed()) {
await completeLesson();
@ -306,15 +319,28 @@
out.success = true;
if (data.expected_output) {
const passed = res.output.trim() === data.expected_output.trim() && checkKeyText(code, data.key_text ?? '');
const currentCCode = currentLanguage === 'c' ? code : cCode;
const currentPythonCode = currentLanguage === 'python' ? code : pythonCode;
const mergedCode = currentCCode + '\n' + currentPythonCode;
const passed = res.output.trim() === data.expected_output.trim() && checkKeyText(mergedCode, data.key_text ?? '');
if (passed) {
codePassed = true;
if (isHybrid && !circuitPassed) {
out.output += '\n✅ Kode benar!\n⏳ Selesaikan juga tantangan rangkaian untuk menyelesaikan pelajaran ini.';
if (currentLanguage === 'c') cPassed = true;
else if (currentLanguage === 'python') pythonPassed = true;
if (!checkAllPassed()) {
out.output += '\n✅ Kode benar!\n⏳ Selesaikan juga tantangan di tab lainnya untuk menyelesaikan pelajaran ini.';
} else {
out.output += '\n🎉 Semuanya benar!';
}
if (checkAllPassed()) {
await completeLesson();
if (data.solution_code) { showSolution = true; editor?.setCode(data.solution_code); }
if (data.solution_code || data.solution_python || data.solution_circuit) {
showSolution = true;
handleShowSolution();
// To avoid logic flipping
showSolution = true;
}
setTimeout(() => { showCelebration = false; activeTab = 'editor'; }, 3000);
}
}
@ -344,18 +370,28 @@
}
function handleShowSolution() {
if (!data?.solution_code) return;
if (!data) return;
if (!data.solution_code && !data.solution_circuit && !data.solution_python) return;
showSolution = !showSolution;
if (showSolution) {
if (activeTab === 'circuit' || data.active_tabs?.includes('circuit')) {
circuitEditor?.setCircuitText(data.solution_code);
} else {
// Update Circuit if exists
if (data.active_tabs?.includes('circuit') && data.solution_circuit) {
circuitEditor?.setCircuitText(data.solution_circuit);
}
// Update Code Editor
if (currentLanguage === 'python' && data.solution_python) {
editor?.setCode(data.solution_python);
} else if (data.solution_code) {
editor?.setCode(data.solution_code);
}
} else {
if (activeTab === 'circuit' || data.active_tabs?.includes('circuit')) {
circuitEditor?.setCircuitText(data.initial_code);
} else {
// Restore Circuit if exists
if (data.active_tabs?.includes('circuit') && data.initial_circuit) {
circuitEditor?.setCircuitText(data.initial_circuit);
}
// Restore Code Editor to their working code
if (currentLanguage === 'python' || currentLanguage === 'c' || data.initial_code) {
editor?.setCode(currentCode);
}
}
@ -363,6 +399,31 @@
// === Velxio (Arduino simulator) ===
/**
* Match serial output using subsequence matching.
* Expected lines must appear in order within actual output (not necessarily consecutive).
* This is more robust than exact line matching.
*/
function matchSerialSubsequence(actual: string, expected: string): boolean {
if (!expected) return true;
if (!actual) return false;
const actualLines = actual.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const expectedLines = expected.split('\n').map(l => l.trim()).filter(l => l.length > 0);
let expectedIdx = 0;
for (const actualLine of actualLines) {
if (expectedIdx < expectedLines.length) {
// Check if expected line is a substring of actual line (case-insensitive)
if (actualLine.toLowerCase().includes(expectedLines[expectedIdx].toLowerCase())) {
expectedIdx++;
}
}
if (expectedIdx === expectedLines.length) return true;
}
return expectedIdx === expectedLines.length;
}
/** Try to directly read Velxio Zustand stores from iframe (same-origin). */
function getVelxioStores(iframe: HTMLIFrameElement): { editor: any; simulator: any } | null {
try {
@ -479,26 +540,56 @@
dbg.push('[fallback: direct iframe access]');
try {
const win = velxioIframe.contentWindow as any;
// Access Zustand stores via window globals (we'll expose them)
// Or try to find the stores on the module scope
const editorStore = win.__VELXIO_EDITOR_STORE__?.getState?.();
const simStore = win.__VELXIO_SIMULATOR_STORE__?.getState?.();
if (editorStore?.files) {
sourceCode = editorStore.files.map((f: any) => f.content).join('\n');
dbg.push(`[direct] source: ${sourceCode.length} chars`);
} else {
dbg.push('[direct] editor store not found');
// Check if stores are exposed
if (!win.__VELXIO_EDITOR_STORE__ || !win.__VELXIO_SIMULATOR_STORE__) {
dbg.push('[direct] WARNING: Stores not exposed on window');
dbg.push('[direct] Trying alternative access methods...');
// Alternative: try to find Zustand stores via other means
// Some bundlers expose stores differently
if (win.__ZUSTAND__) {
dbg.push('[direct] Found __ZUSTAND__, searching for stores...');
// Try to locate editor and simulator stores
for (const key of Object.keys(win.__ZUSTAND__)) {
const store = win.__ZUSTAND__[key];
if (store?.getState) {
const state = store.getState();
if (state?.files && !sourceCode) {
sourceCode = state.files.map((f: any) => f.content).join('\n');
dbg.push(`[direct] source from __ZUSTAND__: ${sourceCode.length} chars`);
}
if (state?.wires !== undefined && wireList.length === 0) {
wireList = state.wires;
dbg.push(`[direct] wires from __ZUSTAND__: ${wireList.length}`);
}
}
}
}
}
// Primary method: use exposed stores
if (!sourceCode || wireList.length === 0) {
const editorStore = win.__VELXIO_EDITOR_STORE__?.getState?.();
const simStore = win.__VELXIO_SIMULATOR_STORE__?.getState?.();
if (simStore) {
const board = simStore.boards?.find((b: any) => b.id === simStore.activeBoardId);
serialLog = board?.serialOutput ?? simStore.serialOutput ?? '';
dbg.push(`[direct] serial: ${serialLog.length} chars`);
wireList = simStore.wires ?? [];
dbg.push(`[direct] wires: ${wireList.length}`);
} else {
dbg.push('[direct] simulator store not found');
if (editorStore?.files) {
sourceCode = editorStore.files.map((f: any) => f.content).join('\n');
dbg.push(`[direct] source: ${sourceCode.length} chars`);
} else {
dbg.push('[direct] editor store has no files');
}
if (simStore) {
// Try to get serial output from active board
const board = simStore.boards?.find((b: any) => b.id === simStore.activeBoardId);
serialLog = board?.serialOutput ?? simStore.serialOutput ?? '';
dbg.push(`[direct] serial: ${serialLog.length} chars`);
wireList = simStore.wires ?? [];
dbg.push(`[direct] wires: ${wireList.length}`);
} else {
dbg.push('[direct] simulator store not found');
}
}
} catch (e: any) {
dbg.push(`[direct] error: ${e.message}`);
@ -523,41 +614,105 @@
// 2. Serial output
if (data.expected_serial_output) {
const actualLines = serialLog.trim().split('\n').map(l => l.trim());
const expectedLines = data.expected_serial_output.trim().split('\n').map(l => l.trim());
let j = 0;
for (const line of actualLines) {
if (j < expectedLines.length && line === expectedLines[j]) j++;
if (j === expectedLines.length) break;
}
serialPass = j === expectedLines.length;
const actualLog = serialLog.trim();
const expectedLog = data.expected_serial_output.trim();
// Use subsequence matching: expected lines must appear in order in actual output
serialPass = matchSerialSubsequence(actualLog, expectedLog);
messages.push(serialPass
? '✅ Serial output sesuai'
: '❌ Serial output belum sesuai');
const preview = serialLog.substring(0, 150).replace(/\n/g, '↵');
dbg.push(`[serial] actual(${serialLog.length}ch)="${preview}" → ${serialPass}`);
dbg.push(`[serial] actual(${serialLog.length}ch)="${preview}"`);
dbg.push(`[serial] expected="${expectedLog.substring(0, 150).replace(/\n/g, '↵')}" → ${serialPass}`);
}
// 3. Wiring
if (data.expected_wiring) {
let expectedPairs: [string, string][] = [];
try { expectedPairs = JSON.parse(data.expected_wiring); } catch {}
let expectedWires: any[] = [];
try {
const parsed = JSON.parse(data.expected_wiring);
// Support both old format (array of pairs) and new format (object with wires array)
if (Array.isArray(parsed)) {
expectedWires = parsed;
} else if (parsed.wires && Array.isArray(parsed.wires)) {
expectedWires = parsed.wires;
}
} catch {}
// Detect non-polarized component types (pins are interchangeable)
const nonPolarizedTypes = new Set(['resistor']);
// Normalize power pin names (e.g., GND.2 → GND, VCC.1 → VCC)
const normalizePin = (pin: string) => pin.replace(/^(GND|VCC|5V|3V3|3\.3V)\.\d+$/i, '$1');
const norm = (a: string, b: string) => {
const normA = a.replace(/:(.+)$/, (_, pin) => ':' + normalizePin(pin));
const normB = b.replace(/:(.+)$/, (_, pin) => ':' + normalizePin(pin));
return [normA, normB].sort().join('↔');
// This handles multiple power pin variants
const normalizePin = (pin: string) => {
// Match power pins with numeric suffixes: GND.1, VCC.2, 5V.3, etc.
return pin.replace(/^(GND|VCC|5V|3V3|3\.3V|POWER)\.\d+$/i, '$1');
};
// Normalize component:pin for comparison
// For non-polarized components (like resistors), strip pin numbers
// so that resistor:1 and resistor:2 are treated as equivalent
const normalizeEdge = (compId: string, pinName: string): string => {
const normPin = normalizePin(pinName);
// Check if this component is non-polarized by looking at expected_wires
// We detect resistor components by their ID pattern or type
const isNonPolarized = nonPolarizedTypes.has(compId.split('-')[0]) ||
compId.startsWith('resistor');
if (isNonPolarized && /^\d+$/.test(normPin)) {
// For non-polarized components with numeric pins, use a generic pin name
return `${compId}:PIN`;
}
return `${compId}:${normPin}`;
};
const norm = (a: string, b: string) => {
// Parse component:pin format
const [compA, pinA] = a.split(':');
const [compB, pinB] = b.split(':');
const normA = `${compA}:${normalizePin(pinA || '')}`;
const normB = `${compB}:${normalizePin(pinB || '')}`;
// For non-polarized components, normalize numeric pins to generic
const finalA = nonPolarizedTypes.has(compA) || compA.startsWith('resistor')
? `${compA}:PIN` : normA;
const finalB = nonPolarizedTypes.has(compB) || compB.startsWith('resistor')
? `${compB}:PIN` : normB;
// Sort to make edge comparison order-independent
return [finalA, finalB].sort().join('↔');
};
const studentEdges = new Set(
wireList.map(w => norm(
`${w.start.componentId}:${w.start.pinName}`,
`${w.end.componentId}:${w.end.pinName}`
))
);
wiringPass = expectedPairs.every(([a, b]) => studentEdges.has(norm(a, b)));
// Check all expected connections exist
wiringPass = expectedWires.every((expected: any) => {
let edgeKey: string;
// Handle both formats
if (Array.isArray(expected) && expected.length === 2) {
// Old format: ["component:pin", "component:pin"]
edgeKey = norm(expected[0], expected[1]);
} else if (expected.start && expected.end) {
// New format: { start: { componentId, pinName }, end: { componentId, pinName } }
const startPin = `${expected.start.componentId}:${expected.start.pinName}`;
const endPin = `${expected.end.componentId}:${expected.end.pinName}`;
edgeKey = norm(startPin, endPin);
} else {
return false;
}
const exists = studentEdges.has(edgeKey);
if (!exists) {
dbg.push(`[wiring] MISSING: ${edgeKey}`);
}
return exists;
});
messages.push(wiringPass
? '✅ Rangkaian wiring benar'
: '❌ Wiring belum sesuai');
@ -565,17 +720,20 @@
const edgesStr = wireList.map(w =>
`${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`
);
dbg.push(`[wiring] student: ${edgesStr.join(' | ') || '(kosong)'}`);
dbg.push(`[wiring] expected: ${expectedPairs.map(p => p.join('↔')).join(' | ')}`);
dbg.push(`[wiring] → ${wiringPass}`);
dbg.push(`[wiring] student (${wireList.length} wires): ${edgesStr.join(' | ') || '(kosong)'}`);
dbg.push(`[wiring] expected (${expectedWires.length} connections): ${expectedWires.map((w: any) => {
if (Array.isArray(w) && w.length === 2) return w.join('↔');
if (w.start && w.end) return `${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`;
return JSON.stringify(w);
}).join(' | ')}`);
dbg.push(`[wiring] result → ${wiringPass}`);
}
const checks = [keyTextPass, serialPass, wiringPass].filter(v => v !== undefined);
const pass = checks.length > 0 && checks.every(Boolean);
messages.push('', '── Debug ──', ...dbg);
velxioOut.output = messages.join('\n');
velxioOut.debug = dbg;
velxioOut.success = pass;
if (pass) {
@ -710,13 +868,7 @@
</div>
{:else}
<div class="velxio-toolbar">
<button type="button" class="btn btn-success" onclick={handleVelxioSubmit}
disabled={velxioOut.loading}>
{velxioOut.loading ? 'Mengevaluasi...' : '✓ Submit'}
</button>
<span class="velxio-status">
{velxioReady ? '🟢 Bridge' : '🔵 Direct'}
</span>
<!-- Submit button removed for cleaner workspace -->
</div>
<!-- svelte-ignore a11y_missing_attribute -->
<iframe
@ -1027,10 +1179,7 @@
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.velxio-status {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.velxio-panel {
display: flex;
flex-direction: column;

File diff suppressed because it is too large Load Diff

View File

@ -54,6 +54,7 @@ def api_lesson(filename):
initial_code = parsed_data['initial_code']
solution_code = parsed_data['solution_code']
solution_circuit = parsed_data.get('solution_circuit', '')
solution_python = parsed_data.get('solution_python', '')
key_text = parsed_data['key_text']
active_tabs = parsed_data['active_tabs']
@ -120,6 +121,7 @@ def api_lesson(filename):
'expected_wiring': expected_wiring,
'solution_code': solution_code,
'solution_circuit': solution_circuit,
'solution_python': solution_python,
'key_text': key_text,
'key_text_circuit': key_text_circuit,
'active_tabs': active_tabs,

View File

@ -311,6 +311,9 @@ def render_markdown_content(file_path):
solution_circuit, lesson_content = _extract_section(
lesson_content, '---SOLUTION_CIRCUIT---', '---END_SOLUTION_CIRCUIT---')
solution_python, lesson_content = _extract_section(
lesson_content, '---SOLUTION_PYTHON---', '---END_SOLUTION_PYTHON---')
# Initial codes (C, Python, Circuit, Quiz)
initial_code_c, lesson_content = _extract_section(
lesson_content, '---INITIAL_CODE---', '---END_INITIAL_CODE---')
@ -367,6 +370,7 @@ def render_markdown_content(file_path):
'initial_code': initial_code,
'solution_code': solution_code,
'solution_circuit': solution_circuit,
'solution_python': solution_python,
'key_text': key_text,
'key_text_circuit': key_text_circuit,
'initial_code_c': initial_code_c,

2
velxio

@ -1 +1 @@
Subproject commit 52321dffe839481eb849a92ec02dd3727246adef
Subproject commit a11dd87d5ad3a7239fb577dc6c2133050484c0a7