diff --git a/frontend/src/lib/components/OutputPanel.svelte b/frontend/src/lib/components/OutputPanel.svelte index 34e331d..96437f3 100644 --- a/frontend/src/lib/components/OutputPanel.svelte +++ b/frontend/src/lib/components/OutputPanel.svelte @@ -1,34 +1,32 @@ @@ -46,42 +44,23 @@
- - {#if hasCode} -
+ {#each sections as sec (sec.key)} +
-
{#if code.loading}Mengompilasi dan menjalankan kode...{:else if code.error}{code.error}{:else if code.output}{code.output}{:else}Klik "Run" untuk menjalankan kode{/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} + {/each} - - {#if hasCircuit} -
- -
{#if circuit.loading}Mengevaluasi rangkaian...{:else if circuit.error}{circuit.error}{:else if circuit.output}{circuit.output}{:else}Klik "Cek Rangkaian" untuk mengevaluasi{/if}
-
- {/if} - - - {#if !hasCode && !hasCircuit} + {#if sections.length === 0}
Tidak ada output
{/if}
diff --git a/frontend/src/lib/components/WorkspaceHeader.svelte b/frontend/src/lib/components/WorkspaceHeader.svelte index 5af173e..8743a27 100644 --- a/frontend/src/lib/components/WorkspaceHeader.svelte +++ b/frontend/src/lib/components/WorkspaceHeader.svelte @@ -5,6 +5,7 @@ isMobile: boolean; mobileMode: 'hidden' | 'half' | 'full'; activeTab: TabType; + currentLanguage: string; hasInfo: boolean; hasExercise: boolean; activeTabs: string[]; @@ -20,6 +21,7 @@ isMobile, mobileMode = $bindable(), activeTab = $bindable(), + currentLanguage = $bindable(), hasInfo, hasExercise, activeTabs, @@ -33,9 +35,12 @@ let touchStartY = 0; + const hasC = $derived(activeTabs?.includes('c') ?? false); + const hasPython = $derived(activeTabs?.includes('python') ?? false); const hasCodeEditor = $derived( - !activeTabs || activeTabs.length === 0 || activeTabs.includes('c') || activeTabs.includes('python') + !activeTabs || activeTabs.length === 0 || hasC || hasPython ); + const hasMultiLang = $derived(hasC && hasPython); const hasCircuit = $derived(activeTabs?.includes('circuit') ?? false); function cycleMobileSheet() { @@ -70,7 +75,12 @@ {/if} {#if hasCodeEditor} - + {#if hasMultiLang} + + + {:else} + + {/if} {/if} {#if hasCircuit} diff --git a/frontend/src/lib/services/exercise.ts b/frontend/src/lib/services/exercise.ts new file mode 100644 index 0000000..7ff83fe --- /dev/null +++ b/frontend/src/lib/services/exercise.ts @@ -0,0 +1,44 @@ +/** + * Pure helper functions for exercise evaluation. + */ + +import type { CircuitJSApi } from '$types/circuitjs'; + +/** Check if code contains all required key_text keywords (one per line). */ +export function checkKeyText(code: string, keyText: string): boolean { + if (!keyText.trim()) return true; + const keys = keyText.split('\n').map(k => k.trim()).filter(k => k.length > 0); + return keys.every(key => code.includes(key)); +} + +export interface NodeResult { + passed: boolean; + message: string; +} + +/** Validate circuit node voltages against expected state. */ +export function validateNodes( + simApi: CircuitJSApi, + nodes: Record +): { allPassed: boolean; messages: string[] } { + let allPassed = true; + const messages: string[] = []; + + for (const [nodeName, criteria] of Object.entries(nodes)) { + const actualV = simApi.getNodeVoltage(nodeName); + if (actualV === undefined || actualV === null) { + allPassed = false; + messages.push(`❌ Node '${nodeName}' tidak ditemukan.`); + continue; + } + const tol = criteria.tolerance || 0.1; + if (Math.abs(actualV - criteria.voltage) <= tol) { + messages.push(`✅ Node '${nodeName}': Tegangan ${actualV.toFixed(2)}V (Sesuai)`); + } else { + allPassed = false; + messages.push(`❌ Node '${nodeName}': Tegangan ${actualV.toFixed(2)}V (Harusnya ~${criteria.voltage}V)`); + } + } + + return { allPassed, messages }; +} diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index 45fff2b..1e9e2f4 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -3,11 +3,12 @@ import { beforeNavigate } from '$app/navigation'; import CodeEditor from '$components/CodeEditor.svelte'; import CircuitEditor from '$components/CircuitEditor.svelte'; - import OutputPanel from '$components/OutputPanel.svelte'; + import OutputPanel, { type OutputEntry } from '$components/OutputPanel.svelte'; import CelebrationOverlay from '$components/CelebrationOverlay.svelte'; import WorkspaceHeader from '$components/WorkspaceHeader.svelte'; import LessonList from '$components/LessonList.svelte'; import { compileCode, trackProgress } from '$services/api'; + import { checkKeyText, validateNodes } from '$services/exercise'; import { auth, authLoggedIn } from '$stores/auth'; import { lessonContext } from '$stores/lessonContext'; import { noSelect } from '$actions/noSelect'; @@ -26,17 +27,20 @@ let data = $state(null); let lessonCompleted = $state(false); let currentCode = $state(''); + let currentLanguage = $state('c'); - // Separate output state for code and circuit - let codeOutput = $state(''); - let codeError = $state(''); - let codeLoading = $state(false); - let codeSuccess = $state(null); + // Per-language code tracking + let cCode = $state(''); + let pythonCode = $state(''); - let circuitOutput = $state(''); - let circuitError = $state(''); - let circuitLoading = $state(false); - let circuitSuccess = $state(null); + // Output state per language + circuit + const freshOutput = () => ({ output: '', error: '', loading: false, success: null as boolean | null }); + let cOut = $state(freshOutput()); + let pyOut = $state(freshOutput()); + let circuitOut = $state(freshOutput()); + + // 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); @@ -49,7 +53,23 @@ ); // Derived: any loading state (for disabling Run button) - let compiling = $derived(codeLoading || circuitLoading); + let compiling = $derived(cOut.loading || pyOut.loading || circuitOut.loading); + + // Build output sections for OutputPanel + let outputSections = $derived.by(() => { + const tabs = data?.active_tabs ?? []; + const secs: OutputEntry[] = []; + if (tabs.includes('c') || (!tabs.length && !tabs.includes('python'))) { + secs.push({ key: 'c', label: 'C', icon: '\u{1F4BB}', data: cOut, placeholder: 'Klik "Run" untuk menjalankan kode C', loadingText: 'Mengompilasi C...' }); + } + if (tabs.includes('python')) { + secs.push({ key: 'python', label: 'Python', icon: '\u{1F40D}', data: pyOut, placeholder: 'Klik "Run" untuk menjalankan kode Python', loadingText: 'Menjalankan Python...' }); + } + if (tabs.includes('circuit')) { + secs.push({ key: 'circuit', label: 'Circuit', icon: '\u26A1', data: circuitOut, placeholder: 'Klik "Cek Rangkaian" untuk mengevaluasi', loadingText: 'Mengevaluasi rangkaian...' }); + } + return secs; + }); // UI state let showSolution = $state(false); @@ -93,19 +113,28 @@ if (lesson) { data = lesson; lessonCompleted = lesson.lesson_completed; - currentCode = lesson.initial_code_c || lesson.initial_python || lesson.initial_code || ''; - codeOutput = ''; - codeError = ''; - codeSuccess = null; - circuitOutput = ''; - circuitError = ''; - circuitSuccess = null; + + // Initialize per-language code + cCode = lesson.initial_code_c || ''; + pythonCode = lesson.initial_python || ''; + + // Determine initial language (use local var to avoid reactive dependency on currentLanguage) + const hasC = lesson.active_tabs?.includes('c'); + const hasPython = lesson.active_tabs?.includes('python'); + const initLang = (hasPython && !hasC) ? 'python' : 'c'; + currentLanguage = initLang; + prevLanguage = initLang; + currentCode = initLang === 'python' ? pythonCode : (cCode || lesson.initial_code || ''); + + cOut = freshOutput(); + pyOut = freshOutput(); + circuitOut = freshOutput(); codePassed = false; circuitPassed = false; showSolution = false; if (lesson.lesson_info) activeTab = 'info'; else if (lesson.exercise_content) activeTab = 'exercise'; - else if (lesson.active_tabs?.includes('circuit') && !lesson.active_tabs?.includes('c') && !lesson.active_tabs?.includes('python')) activeTab = 'circuit'; + else if (lesson.active_tabs?.includes('circuit') && !hasC && !hasPython) activeTab = 'circuit'; else activeTab = 'editor'; mobileMode = 'half'; @@ -119,6 +148,21 @@ } }); + // Switch editor content when language tab changes + let prevLanguage = $state('c'); + $effect(() => { + if (!data || currentLanguage === prevLanguage) return; + // Save current code to the previous language slot + const code = editor?.getCode() ?? currentCode; + if (prevLanguage === 'c') cCode = code; + else if (prevLanguage === 'python') pythonCode = code; + // Load code for the new language + const newCode = currentLanguage === 'python' ? pythonCode : (cCode || data.initial_code || ''); + currentCode = newCode; + editor?.setCode(newCode); + prevLanguage = currentLanguage; + }); + // Clear lesson context when leaving page beforeNavigate(() => { lessonContext.set(null); @@ -140,13 +184,6 @@ } }); - /** Check if student code contains all required key_text keywords. */ - function checkKeyText(code: string, keyText: string): boolean { - if (!keyText.trim()) return true; - const keys = keyText.split('\n').map(k => k.trim()).filter(k => k.length > 0); - return keys.every(key => code.includes(key)); - } - /** Mark lesson as complete: track progress + celebration. Called when ALL exercises pass. */ async function completeLesson() { showCelebration = true; @@ -168,159 +205,102 @@ if (!data || !circuitEditor) return; const simApi = circuitEditor.getApi(); if (!simApi) { - circuitError = "Simulator belum siap."; - circuitSuccess = false; + Object.assign(circuitOut, { error: "Simulator belum siap.", success: false }); activeTab = 'output'; return; } - circuitLoading = true; - circuitOutput = 'Mengevaluasi rangkaian...'; - circuitError = ''; - circuitSuccess = null; + Object.assign(circuitOut, { loading: true, output: 'Mengevaluasi rangkaian...', error: '', success: null }); activeTab = 'output'; try { - // For hybrid lessons, use expected_circuit_output; otherwise fallback to expected_output const circuitExpected = (isHybrid && data.expected_circuit_output) ? data.expected_circuit_output : data.expected_output; let expectedState: any = null; try { - if (circuitExpected) { - expectedState = JSON.parse(circuitExpected); - } - } catch (e) { - circuitError = "Format EXPECTED_OUTPUT tidak valid (Harus JSON)."; - circuitSuccess = false; - circuitLoading = false; + if (circuitExpected) expectedState = JSON.parse(circuitExpected); + } catch { + Object.assign(circuitOut, { error: "Format EXPECTED_OUTPUT tidak valid (Harus JSON).", success: false, loading: false }); return; } if (!expectedState) { - circuitOutput = "Tidak ada kriteria evaluasi yang ditetapkan."; - circuitSuccess = true; - circuitLoading = false; + Object.assign(circuitOut, { output: "Tidak ada kriteria evaluasi yang ditetapkan.", success: true, loading: false }); return; } - let allPassed = true; - let messages: string[] = []; - - if (expectedState.nodes) { - for (const [nodeName, criteria] of Object.entries(expectedState.nodes)) { - const actualV = simApi.getNodeVoltage(nodeName); - if (actualV === undefined || actualV === null) { - allPassed = false; - messages.push(`❌ Node '${nodeName}' tidak ditemukan.`); - continue; - } - - const expectedV = criteria.voltage; - const tol = criteria.tolerance || 0.1; - if (Math.abs(actualV - expectedV) <= tol) { - messages.push(`✅ Node '${nodeName}': Tegangan ${actualV.toFixed(2)}V (Sesuai)`); - } else { - allPassed = false; - messages.push(`❌ Node '${nodeName}': Tegangan ${actualV.toFixed(2)}V (Harusnya ~${expectedV}V)`); - } - } - } - - // TODO: Element-level checking (e.g. expectedState.elements) belum diimplementasi. - // GWT getInfo() returns Java array yang sulit di-parse dari JS. + let { allPassed, messages } = expectedState.nodes + ? validateNodes(simApi, expectedState.nodes) + : { allPassed: true, messages: [] as string[] }; const circuitText = circuitEditor.getCircuitText(); - // For hybrid lessons, use key_text_circuit; otherwise fallback to key_text - const circuitKeyText = (isHybrid && data.key_text_circuit) - ? data.key_text_circuit - : data.key_text; - const keyTextMatch = checkKeyText(circuitText, circuitKeyText ?? ''); - if (!keyTextMatch) { + const circuitKeyText = (isHybrid && data.key_text_circuit) ? data.key_text_circuit : data.key_text; + if (!checkKeyText(circuitText, circuitKeyText ?? '')) { allPassed = false; messages.push(`❌ Komponen wajib belum lengkap (lihat instruksi).`); } - circuitOutput = messages.join('\n'); - circuitSuccess = allPassed; + circuitOut.output = messages.join('\n'); + circuitOut.success = allPassed; if (allPassed) { circuitPassed = true; if (isHybrid) { - circuitOutput += '\n✅ Rangkaian benar!'; - if (!codePassed) { - circuitOutput += '\n⏳ Selesaikan juga tantangan kode untuk menyelesaikan pelajaran ini.'; - } + circuitOut.output += '\n✅ Rangkaian benar!'; + if (!codePassed) circuitOut.output += '\n⏳ Selesaikan juga tantangan kode untuk menyelesaikan pelajaran ini.'; } if (checkAllPassed()) { await completeLesson(); - setTimeout(() => { - showCelebration = false; - activeTab = 'circuit'; - }, 3000); + setTimeout(() => { showCelebration = false; activeTab = 'circuit'; }, 3000); } } } catch (err: any) { - circuitError = `Evaluasi gagal: ${err.message}`; - circuitSuccess = false; + Object.assign(circuitOut, { error: `Evaluasi gagal: ${err.message}`, success: false }); } finally { - circuitLoading = false; + circuitOut.loading = false; } } async function handleRun() { - if (activeTab === 'circuit') { - await evaluateCircuit(); - return; - } + if (activeTab === 'circuit') { await evaluateCircuit(); return; } if (!data) return; - codeLoading = true; - codeOutput = ''; - codeError = ''; - codeSuccess = null; + + const out = getCodeOut(); + Object.assign(out, { loading: true, output: '', error: '', success: null }); activeTab = 'output'; try { const code = editor?.getCode() ?? currentCode; - const res = await compileCode({ code, language: data.language }); + const res = await compileCode({ code, language: currentLanguage }); - if (res.success) { - codeOutput = res.output; - codeSuccess = true; + if (!res.success) { + Object.assign(out, { error: res.error || 'Compilation failed', success: false }); + return; + } - if (data.expected_output) { - const outputMatch = res.output.trim() === data.expected_output.trim(); - const keyTextMatch = checkKeyText(code, data.key_text ?? ''); - if (outputMatch && keyTextMatch) { - codePassed = true; - if (isHybrid && !circuitPassed) { - codeOutput += '\n✅ Kode benar!'; - codeOutput += '\n⏳ Selesaikan juga tantangan rangkaian untuk menyelesaikan pelajaran ini.'; - } - if (checkAllPassed()) { - await completeLesson(); - // Auto-show solution after celebration - if (data.solution_code) { - showSolution = true; - editor?.setCode(data.solution_code); - } - setTimeout(() => { - showCelebration = false; - activeTab = 'editor'; - }, 3000); - } + out.output = res.output; + out.success = true; + + if (data.expected_output) { + const passed = res.output.trim() === data.expected_output.trim() && checkKeyText(code, 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 (checkAllPassed()) { + await completeLesson(); + if (data.solution_code) { showSolution = true; editor?.setCode(data.solution_code); } + setTimeout(() => { showCelebration = false; activeTab = 'editor'; }, 3000); } } - } else { - codeError = res.error || 'Compilation failed'; - codeSuccess = false; } } catch { - codeError = 'Gagal terhubung ke server'; - codeSuccess = false; + Object.assign(out, { error: 'Gagal terhubung ke server', success: false }); } finally { - codeLoading = false; + out.loading = false; } } @@ -328,15 +308,16 @@ if (!data) return; if (activeTab === 'circuit') { circuitEditor?.setCircuitText(data.initial_circuit || data.initial_code); - circuitOutput = ''; - circuitError = ''; - circuitSuccess = null; + Object.assign(circuitOut, freshOutput()); } else { - currentCode = data.initial_code; - editor?.setCode(data.initial_code); - codeOutput = ''; - codeError = ''; - codeSuccess = null; + const resetCode = currentLanguage === 'python' + ? (data.initial_python || '') + : (data.initial_code_c || data.initial_code || ''); + currentCode = resetCode; + if (currentLanguage === 'c') cCode = resetCode; + else pythonCode = resetCode; + editor?.setCode(resetCode); + Object.assign(getCodeOut(), freshOutput()); } } @@ -403,6 +384,7 @@ {isMobile} bind:mobileMode bind:activeTab + bind:currentLanguage hasInfo={!!data.lesson_info} hasExercise={!!data.exercise_content} activeTabs={data.active_tabs ?? []} @@ -481,18 +463,20 @@ {showSolution ? 'Sembunyikan Solusi' : 'Lihat Solusi'} {/if} - {data.language_display_name} + {currentLanguage === 'python' ? 'Python' : 'C'}
+ {#key currentLanguage} { if (!showSolution) currentCode = val; }} /> + {/key}
{#if data.expected_output} @@ -507,12 +491,7 @@
- +
@@ -523,26 +502,35 @@ {/if}