diff --git a/examples/content/hello_world.md b/examples/content/hello_world.md index 717c282..eba0ff7 100644 --- a/examples/content/hello_world.md +++ b/examples/content/hello_world.md @@ -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 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 + +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--- diff --git a/examples/content/home.md b/examples/content/home.md index d1547c0..cb34ecc 100644 --- a/examples/content/home.md +++ b/examples/content/home.md @@ -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) diff --git a/examples/content/led_blink_arduino.md b/examples/content/led_blink_arduino.md new file mode 100644 index 0000000..14061be --- /dev/null +++ b/examples/content/led_blink_arduino.md @@ -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 (0–13). 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--- diff --git a/examples/content/rangkaian_dasar.md b/examples/content/rangkaian_dasar.md index 39caaee..6178b05 100644 --- a/examples/content/rangkaian_dasar.md +++ b/examples/content/rangkaian_dasar.md @@ -73,17 +73,31 @@ Lengkapi rangkaian agar tegangan di **Vout** bernilai **2.5V**. #include 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--- + @@ -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 ----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--- + + + + + + + +---END_SOLUTION_CIRCUIT--- ---KEY_TEXT--- printf +float +print(f" ---END_KEY_TEXT--- ---KEY_TEXT_CIRCUIT--- diff --git a/examples/content/variabel.md b/examples/content/variabel.md index 19fa772..d751b5e 100644 --- a/examples/content/variabel.md +++ b/examples/content/variabel.md @@ -78,8 +78,8 @@ Panjang nama: 10 #include 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 + +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--- diff --git a/examples/tokens_siswa.csv b/examples/tokens_siswa.csv index 3b61baa..9aaa711 100644 --- a/examples/tokens_siswa.csv +++ b/examples/tokens_siswa.csv @@ -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 diff --git a/frontend/src/lib/components/CircuitEditor.svelte b/frontend/src/lib/components/CircuitEditor.svelte index 784aa41..142230b 100644 --- a/frontend/src/lib/components/CircuitEditor.svelte +++ b/frontend/src/lib/components/CircuitEditor.svelte @@ -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); } }); diff --git a/frontend/src/lib/components/CodeEditor.svelte b/frontend/src/lib/components/CodeEditor.svelte index e123b4a..8afa685 100644 --- a/frontend/src/lib/components/CodeEditor.svelte +++ b/frontend/src/lib/components/CodeEditor.svelte @@ -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 { diff --git a/frontend/src/lib/components/OutputPanel.svelte b/frontend/src/lib/components/OutputPanel.svelte index 96437f3..bcc29d9 100644 --- a/frontend/src/lib/components/OutputPanel.svelte +++ b/frontend/src/lib/components/OutputPanel.svelte @@ -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>({}); + let anyLoading = $derived(sections.some(s => s.data.loading)); let overallSuccess = $derived.by(() => { @@ -55,8 +58,16 @@ {:else if sec.data.success === false} Error {/if} + {#if sec.data.debug && sec.data.debug.length > 0} + + {/if} -
{#if sec.data.loading}{sec.loadingText}{:else if sec.data.error}{sec.data.error}{:else if sec.data.output}{sec.data.output}{:else}{sec.placeholder}{/if}
+
{#if sec.data.loading}{sec.loadingText}{:else if sec.data.error}{sec.data.error}{:else if sec.data.output}{sec.data.output}{:else}{sec.placeholder}{/if}{#if sec.data.debug && showDebug[sec.key]}
+
+── Debug ──
+{sec.data.debug.join('\n')}{/if}
{/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; + } diff --git a/frontend/src/lib/services/velxio-bridge.ts b/frontend/src/lib/services/velxio-bridge.ts index 5a51daf..b627641 100644 --- a/frontend/src/lib/services/velxio-bridge.ts +++ b/frontend/src/lib/services/velxio-bridge.ts @@ -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 (A↔B vs B↔A) + * - 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(); + const rank = new Map(); + + 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(); + 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>(); + 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(); + const expectedRank = new Map(); + + 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(); + for (const wire of expectedWires) { + allExpectedPins.add(wire.start); + allExpectedPins.add(wire.end); + } + + const expectedNets = new Map>(); + 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; } } diff --git a/frontend/src/lib/types/lesson.ts b/frontend/src/lib/types/lesson.ts index c92a140..6c645f5 100644 --- a/frontend/src/lib/types/lesson.ts +++ b/frontend/src/lib/types/lesson.ts @@ -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; diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index 6cc1a3f..d383c30 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -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 @@ {:else}
- - - {velxioReady ? '🟢 Bridge' : '🔵 Direct'} - +