feat: custom evaluation config
This commit is contained in:
parent
120a1338c0
commit
e4ec3f1ded
|
|
@ -198,6 +198,7 @@ Untuk materi yang menggunakan simulator Arduino (Velxio):
|
|||
| `---EXPECTED_SERIAL_OUTPUT---` | Output serial yang diharapkan (subsequence match) |
|
||||
| `---EXPECTED_WIRING---` | Wiring yang harus dibuat siswa (JSON, lenient) |
|
||||
| `---KEY_TEXT---` | Kata kunci yang harus ada di kode siswa |
|
||||
| `---EVALUATION_CONFIG---` | Konfigurasi tambahan evaluasi Arduino (JSON: e.g. `timeout_ms` dalam milidetik) |
|
||||
|
||||
Contoh materi Arduino:
|
||||
|
||||
|
|
@ -246,6 +247,12 @@ LED OFF
|
|||
pinMode
|
||||
digitalWrite
|
||||
---END_KEY_TEXT---
|
||||
|
||||
---EVALUATION_CONFIG---
|
||||
{
|
||||
"timeout_ms": 8000
|
||||
}
|
||||
---END_EVALUATION_CONFIG---
|
||||
```
|
||||
|
||||
##### Referensi Nama Pin Komponen Velxio
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ Elemes mendukung beberapa mode lesson melalui **marker** di file markdown. Mode
|
|||
| `---KEY_TEXT_CIRCUIT---` / `---END_KEY_TEXT_CIRCUIT---` | Keyword wajib di circuit (hybrid) |
|
||||
| `---SOLUTION_CODE---` / `---END_SOLUTION_CODE---` | Solusi kode (ditampilkan setelah selesai) |
|
||||
| `---SOLUTION_CIRCUIT---` / `---END_SOLUTION_CIRCUIT---` | Solusi circuit |
|
||||
| `---EVALUATION_CONFIG---` / `---END_EVALUATION_CONFIG---` | Konfigurasi tambahan evaluasi Arduino (JSON: e.g. `timeout_ms`) |
|
||||
|
||||
### Fitur Tombol "Coba" (Code Try-out)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,5 +14,14 @@
|
|||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||
.then(reg => console.log('[SW] Registered:', reg.scope))
|
||||
.catch(err => console.warn('[SW] Registration failed:', err));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -15,18 +15,21 @@
|
|||
let ready = $state(false);
|
||||
let simApi = $state<CircuitJSApi | null>(null);
|
||||
let saving = $state(false);
|
||||
let saveTimeout: ReturnType<typeof setTimeout>;
|
||||
let autoSaveInterval: ReturnType<typeof setInterval>;
|
||||
let saveTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
let isDestroyed = false;
|
||||
let lastLoadedCircuit = $state('');
|
||||
let lastStorageKey = $state<string | undefined>(undefined);
|
||||
|
||||
function saveToStorage(text: string) {
|
||||
if (!storageKey) return;
|
||||
if (!storageKey || isDestroyed) return;
|
||||
saving = true;
|
||||
clearTimeout(saveTimeout);
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(() => {
|
||||
localStorage.setItem(storageKey, text);
|
||||
if (!isDestroyed && storageKey) {
|
||||
localStorage.setItem(storageKey, text);
|
||||
}
|
||||
saving = false;
|
||||
saveTimeout = null;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
|
|
@ -137,8 +140,11 @@
|
|||
// Cleanup timers on destroy
|
||||
$effect(() => {
|
||||
return () => {
|
||||
clearInterval(autoSaveInterval);
|
||||
clearTimeout(saveTimeout);
|
||||
isDestroyed = true;
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -44,9 +44,12 @@ export function initVelxioBridge(
|
|||
arduinoCircuitKey: string,
|
||||
arduinoCodeKey: string,
|
||||
onReady: (bridge: VelxioBridge) => void,
|
||||
onSubmit: () => void
|
||||
) {
|
||||
onSubmit: () => void,
|
||||
onCountdown?: (msRemaining: number) => void
|
||||
): () => void {
|
||||
let settled = false;
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
const type = e.data?.type;
|
||||
if (!type) return;
|
||||
|
|
@ -83,8 +86,30 @@ export function initVelxioBridge(
|
|||
}
|
||||
|
||||
if (type === 'velxio:compile_result' && e.data.success) {
|
||||
const timeout = data?.evaluation_config?.timeout_ms ?? 5000;
|
||||
setTimeout(() => onSubmit(), timeout);
|
||||
const timeout = data?.evaluation_config?.timeout_ms ?? 8000;
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
let remaining = timeout;
|
||||
if (onCountdown) {
|
||||
onCountdown(remaining);
|
||||
countdownInterval = setInterval(() => {
|
||||
remaining -= 1000;
|
||||
if (remaining <= 0) {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
} else {
|
||||
onCountdown(remaining);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
onSubmit();
|
||||
}, timeout);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
|
|
@ -136,10 +161,18 @@ export function initVelxioBridge(
|
|||
} catch { /* cross-origin or not ready yet */ }
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(pollReady);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('message', onMessage);
|
||||
clearInterval(pollReady);
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
}, 30_000);
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,14 @@
|
|||
|
||||
beforeNavigate(() => {
|
||||
lessonContext.set(null);
|
||||
if (mgr.velxioBridge) { mgr.velxioBridge.destroy(); mgr.velxioBridge = null; }
|
||||
if (mgr.velxioCleanup) {
|
||||
mgr.velxioCleanup();
|
||||
mgr.velxioCleanup = null;
|
||||
}
|
||||
if (mgr.velxioBridge) {
|
||||
mgr.velxioBridge.destroy();
|
||||
mgr.velxioBridge = null;
|
||||
}
|
||||
});
|
||||
|
||||
let contentEl = $state<HTMLElement | null>(null);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export class LessonManager {
|
|||
velxioSaving = $state(false);
|
||||
velxioError = $state(false);
|
||||
velxioIframe = $state<HTMLIFrameElement | null>(null);
|
||||
velxioCleanup = $state<(() => void) | null>(null);
|
||||
|
||||
showSolution = $state(false);
|
||||
activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart'>('info');
|
||||
|
|
@ -177,6 +178,7 @@ export class LessonManager {
|
|||
this.circuitPassed = false;
|
||||
this.showSolution = false;
|
||||
|
||||
if (this.velxioCleanup) { this.velxioCleanup(); this.velxioCleanup = null; }
|
||||
if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; }
|
||||
this.velxioReady = false;
|
||||
this.velxioError = false;
|
||||
|
|
@ -411,7 +413,11 @@ export class LessonManager {
|
|||
|
||||
setupVelxioBridge(iframe: HTMLIFrameElement) {
|
||||
this.velxioIframe = iframe;
|
||||
initVelxioBridge(
|
||||
if (this.velxioCleanup) {
|
||||
this.velxioCleanup();
|
||||
this.velxioCleanup = null;
|
||||
}
|
||||
this.velxioCleanup = initVelxioBridge(
|
||||
iframe,
|
||||
this.data,
|
||||
this.arduinoCircuitKey,
|
||||
|
|
@ -420,7 +426,16 @@ export class LessonManager {
|
|||
this.velxioBridge = bridge;
|
||||
this.velxioReady = true;
|
||||
},
|
||||
() => this.handleVelxioSubmit()
|
||||
() => this.handleVelxioSubmit(),
|
||||
(msRemaining) => {
|
||||
this.activeTab = 'output';
|
||||
Object.assign(this.velxioOut, {
|
||||
loading: true,
|
||||
output: `Kompilasi sukses. Menjalankan simulasi & merekam log serial (${Math.round(msRemaining / 1000)} detik)...`,
|
||||
error: '',
|
||||
success: null
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
|
|
@ -1,21 +1,32 @@
|
|||
{
|
||||
"name": "Elemes LMS",
|
||||
"short_name": "Elemes",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0d6efd",
|
||||
"description": "Belajar Pemrograman C",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
"name": "Elemes LMS",
|
||||
"short_name": "Elemes",
|
||||
"description": "Belajar Pemrograman C & Arduino",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0d6efd",
|
||||
"categories": ["education"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Daftar Pelajaran",
|
||||
"url": "/",
|
||||
"description": "Lihat semua pelajaran"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
// static/sw.js
|
||||
const CACHE_VERSION = 'elemes-v1';
|
||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
// Assets yang di-precache (Vite immutable + critical)
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// Install: precache minimal assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE).then(cache => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate: hapus cache lama
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys
|
||||
.filter(k => k.startsWith('elemes-') && k !== STATIC_CACHE && k !== API_CACHE && k !== ASSET_CACHE)
|
||||
.map(k => caches.delete(k))
|
||||
)
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Hanya handle GET
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
// NetworkOnly: API yang tidak boleh di-cache
|
||||
if (url.pathname.startsWith('/api/compile') ||
|
||||
url.pathname.startsWith('/api/track-progress') ||
|
||||
url.pathname.startsWith('/api/login') ||
|
||||
url.pathname.startsWith('/api/logout')) {
|
||||
return; // biarkan browser handle
|
||||
}
|
||||
|
||||
// CacheFirst: Static assets (Vite immutable dengan hash)
|
||||
if (url.pathname.startsWith('/_app/immutable/')) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE, 365));
|
||||
return;
|
||||
}
|
||||
|
||||
// CacheFirst: Gambar lesson (assets)
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(cacheFirst(request, ASSET_CACHE, 7));
|
||||
return;
|
||||
}
|
||||
|
||||
// CacheFirst: Font files (immutable)
|
||||
if (url.hostname === 'fonts.gstatic.com') {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE, 365));
|
||||
return;
|
||||
}
|
||||
|
||||
// NetworkFirst: API lesson data (cache 6 jam)
|
||||
if (url.pathname.startsWith('/api/lesson/') && url.pathname.endsWith('.json')) {
|
||||
event.respondWith(networkFirst(request, API_CACHE, 6 * 60));
|
||||
return;
|
||||
}
|
||||
|
||||
// NetworkFirst: API lesson list (cache 1 jam)
|
||||
if (url.pathname.startsWith('/api/lessons')) {
|
||||
event.respondWith(networkFirst(request, API_CACHE, 60));
|
||||
return;
|
||||
}
|
||||
|
||||
// StaleWhileRevalidate: HTML pages
|
||||
if (request.headers.get('accept')?.includes('text/html')) {
|
||||
event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// StaleWhileRevalidate: Google Fonts CSS, KaTeX
|
||||
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'cdn.jsdelivr.net') {
|
||||
event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function cacheFirst(request, cacheName, maxAgeDays = 7) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
}
|
||||
|
||||
async function networkFirst(request, cacheName, maxAgeMinutes = 60) {
|
||||
const cache = await caches.open(cacheName);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
return new Response(JSON.stringify({ error: 'Offline', cached: false }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
const networkFetch = fetch(request).then(response => {
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
}).catch(() => null);
|
||||
return cached || (await networkFetch) ||
|
||||
new Response('Offline — halaman ini belum pernah dibuka sebelumnya.', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
|
@ -6,20 +6,42 @@ import os
|
|||
import re
|
||||
import html as html_module
|
||||
from functools import lru_cache
|
||||
from threading import Lock
|
||||
|
||||
import markdown as md
|
||||
|
||||
from config import CONTENT_DIR
|
||||
|
||||
_home_cache = {'content': None, 'mtime': -1.0}
|
||||
_home_lock = Lock()
|
||||
|
||||
_markdown_cache = {}
|
||||
_markdown_lock = Lock()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _read_home_md():
|
||||
"""Read home.md and return its content, or empty string if missing."""
|
||||
path = os.path.join(CONTENT_DIR, "home.md")
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
try:
|
||||
current_mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
return _home_cache.get('content') or ""
|
||||
|
||||
with _home_lock:
|
||||
if current_mtime != _home_cache['mtime']:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
_home_cache['content'] = f.read()
|
||||
_home_cache['mtime'] = current_mtime
|
||||
# Invalidate all downstream caches
|
||||
find_lesson_file.cache_clear()
|
||||
get_lessons.cache_clear()
|
||||
get_lesson_names.cache_clear()
|
||||
get_lessons_with_learning_objectives.cache_clear()
|
||||
with _markdown_lock:
|
||||
_markdown_cache.clear()
|
||||
return _home_cache['content']
|
||||
|
||||
|
||||
def _parse_lesson_links(home_content):
|
||||
|
|
@ -417,9 +439,18 @@ def _extract_section(content, start_marker, end_marker):
|
|||
return extracted, remaining
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def render_markdown_content(file_path):
|
||||
"""Parse a lesson markdown file and return structured HTML parts as a dictionary."""
|
||||
try:
|
||||
current_mtime = os.path.getmtime(file_path)
|
||||
except OSError:
|
||||
current_mtime = 0.0
|
||||
|
||||
with _markdown_lock:
|
||||
cached = _markdown_cache.get(file_path)
|
||||
if cached and cached['mtime'] == current_mtime:
|
||||
return cached['data']
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
|
|
@ -586,7 +617,7 @@ def render_markdown_content(file_path):
|
|||
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
|
||||
lesson_info_html = md.markdown(lesson_info, extensions=MD_EXTENSIONS) if lesson_info else ""
|
||||
|
||||
return {
|
||||
parsed_data = {
|
||||
'lesson_html': lesson_html,
|
||||
'exercise_html': exercise_html,
|
||||
'expected_output': expected_output,
|
||||
|
|
@ -615,10 +646,21 @@ def render_markdown_content(file_path):
|
|||
'slides': slides_html
|
||||
}
|
||||
|
||||
with _markdown_lock:
|
||||
if len(_markdown_cache) >= 128:
|
||||
_markdown_cache.clear()
|
||||
_markdown_cache[file_path] = {'data': parsed_data, 'mtime': current_mtime}
|
||||
|
||||
return parsed_data
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def render_home_content():
|
||||
"""Render the home.md intro section (before Available_Lessons) as HTML."""
|
||||
"""Render the home.md intro section (before Available_Lessons) as HTML.
|
||||
|
||||
Not cached separately — relies on _read_home_md() mtime-based cache,
|
||||
which is already fast (1 syscall per request). This avoids the lru_cache
|
||||
multi-worker stale data problem.
|
||||
"""
|
||||
home_content = _read_home_md()
|
||||
if not home_content:
|
||||
return ""
|
||||
|
|
|
|||
Loading…
Reference in New Issue