406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
/**
|
|
* PostMessage bridge for communicating with Velxio iframe (Arduino simulator).
|
|
*
|
|
* Elemes sends commands (load code, load circuit, get state) and Velxio
|
|
* responds with events. Evaluation (serial, key_text, wiring) runs on the
|
|
* Elemes side using data received from Velxio.
|
|
*/
|
|
|
|
import { checkKeyText } from './exercise';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface VelxioFile {
|
|
name: string;
|
|
content: string;
|
|
}
|
|
|
|
interface VelxioWire {
|
|
start: { componentId: string; pinName: string };
|
|
end: { componentId: string; pinName: string };
|
|
}
|
|
|
|
interface EvaluationExpected {
|
|
key_text?: string;
|
|
serial_output?: string;
|
|
wiring?: [string, string][] | { wires: Array<{ start: { componentId: string; pinName: string }; end: { componentId: string; pinName: string } }> };
|
|
}
|
|
|
|
export interface EvaluationResult {
|
|
pass: boolean;
|
|
key_text?: boolean;
|
|
serial?: boolean;
|
|
wiring?: boolean;
|
|
messages: string[];
|
|
debug?: string[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bridge
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const READY_TIMEOUT_MS = 30_000;
|
|
const REQUEST_TIMEOUT_MS = 5_000;
|
|
|
|
export class VelxioBridge {
|
|
private iframe: HTMLIFrameElement;
|
|
private pending: Record<string, (data: any) => void> = {};
|
|
private onReadyCallback: (() => void) | null = null;
|
|
private boundOnMessage: (e: MessageEvent) => void;
|
|
|
|
constructor(iframe: HTMLIFrameElement) {
|
|
this.iframe = iframe;
|
|
this.boundOnMessage = this.onMessage.bind(this);
|
|
window.addEventListener('message', this.boundOnMessage);
|
|
}
|
|
|
|
// === Lifecycle ===
|
|
|
|
/** Register a callback for when Velxio iframe reports ready. */
|
|
onReady(callback: () => void) {
|
|
this.onReadyCallback = callback;
|
|
}
|
|
|
|
/** Wait for velxio:ready with timeout. Returns false if timed out. */
|
|
waitForReady(): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => resolve(false), READY_TIMEOUT_MS);
|
|
this.onReady(() => {
|
|
clearTimeout(timer);
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
destroy() {
|
|
window.removeEventListener('message', this.boundOnMessage);
|
|
this.pending = {};
|
|
}
|
|
|
|
// === Commands (Elemes → Velxio) ===
|
|
|
|
loadCode(files: VelxioFile[]) {
|
|
this.send('elemes:load_code', { files });
|
|
}
|
|
|
|
loadCircuit(circuitJson: string) {
|
|
try {
|
|
const data = JSON.parse(circuitJson);
|
|
this.send('elemes:load_circuit', data);
|
|
} catch {
|
|
console.error('VelxioBridge: invalid circuit JSON');
|
|
}
|
|
}
|
|
|
|
setEmbedMode(options: { hideEditor?: boolean; hideAuth?: boolean; hideComponentPicker?: boolean; lockComponents?: boolean }) {
|
|
this.send('elemes:set_embed_mode', options);
|
|
}
|
|
|
|
// === Evaluation ===
|
|
|
|
async evaluate(expected: EvaluationExpected): Promise<EvaluationResult> {
|
|
const result: EvaluationResult = { pass: false, messages: [] };
|
|
const dbg: string[] = [];
|
|
|
|
// 1. Key text check
|
|
if (expected.key_text) {
|
|
const resp = await this.request('elemes:get_source_code', 'velxio:source_code');
|
|
if (resp) {
|
|
const allCode = (resp.files as VelxioFile[]).map(f => f.content).join('\n');
|
|
result.key_text = checkKeyText(allCode, expected.key_text);
|
|
result.messages.push(result.key_text
|
|
? '✅ Kata kunci ditemukan dalam kode'
|
|
: '❌ Kata kunci yang dibutuhkan belum ada dalam kode');
|
|
dbg.push(`[DBG key_text] keys="${expected.key_text.replace(/\n/g, ', ')}" → ${result.key_text}`);
|
|
} else {
|
|
result.key_text = false;
|
|
result.messages.push('❌ Gagal mengambil source code dari simulator');
|
|
dbg.push('[DBG key_text] resp=null (timeout)');
|
|
}
|
|
}
|
|
|
|
// 2. Serial output
|
|
if (expected.serial_output) {
|
|
const resp = await this.request('elemes:get_serial_log', 'velxio:serial_log');
|
|
if (resp) {
|
|
const actualLog = resp.log as string;
|
|
result.serial = this.matchSerial(actualLog, expected.serial_output);
|
|
result.messages.push(result.serial
|
|
? '✅ Serial output sesuai'
|
|
: '❌ Serial output belum sesuai dengan yang diharapkan');
|
|
dbg.push(`[DBG serial] actual(${actualLog.length} chars)="${actualLog.substring(0, 120).replace(/\n/g, '↵')}" → ${result.serial}`);
|
|
} else {
|
|
result.serial = false;
|
|
result.messages.push('❌ Gagal mengambil serial log. Pastikan program sudah di-Run terlebih dahulu');
|
|
dbg.push('[DBG serial] resp=null (timeout)');
|
|
}
|
|
}
|
|
|
|
// 3. Wiring
|
|
if (expected.wiring) {
|
|
const resp = await this.request('elemes:get_wires', 'velxio:wires');
|
|
if (resp) {
|
|
const studentWires = resp.wires as VelxioWire[];
|
|
const edges = studentWires.map(w =>
|
|
`${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`
|
|
);
|
|
|
|
// 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: ${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;
|
|
result.messages.push('❌ Gagal mengambil data wiring dari simulator');
|
|
dbg.push('[DBG wiring] resp=null (timeout)');
|
|
}
|
|
}
|
|
|
|
// Overall pass: all checked criteria must be true
|
|
const checks = [result.key_text, result.serial, result.wiring].filter(v => v !== undefined);
|
|
result.pass = checks.length > 0 && checks.every(Boolean);
|
|
|
|
// Store debug info separately
|
|
result.debug = dbg;
|
|
|
|
return result;
|
|
}
|
|
|
|
// === Internal ===
|
|
|
|
private send(type: string, payload: Record<string, any> = {}) {
|
|
this.iframe.contentWindow?.postMessage({ type, ...payload }, '*');
|
|
}
|
|
|
|
private request(sendType: string, expectType: string): Promise<any | null> {
|
|
return new Promise((resolve) => {
|
|
this.pending[expectType] = resolve;
|
|
this.send(sendType);
|
|
setTimeout(() => {
|
|
if (this.pending[expectType]) {
|
|
delete this.pending[expectType];
|
|
resolve(null);
|
|
}
|
|
}, REQUEST_TIMEOUT_MS);
|
|
});
|
|
}
|
|
|
|
private onMessage(event: MessageEvent) {
|
|
const { type } = event.data || {};
|
|
if (!type?.startsWith('velxio:')) return;
|
|
|
|
if (type === 'velxio:ready' && this.onReadyCallback) {
|
|
this.onReadyCallback();
|
|
}
|
|
|
|
if (this.pending[type]) {
|
|
this.pending[type](event.data);
|
|
delete this.pending[type];
|
|
}
|
|
}
|
|
|
|
/** Subsequence match: expected lines must appear in order within actual. */
|
|
private matchSerial(actual: string, expected: string): boolean {
|
|
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 expectedIdx === expectedLines.length;
|
|
}
|
|
|
|
/**
|
|
* 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) => {
|
|
return pin.replace(/^(GND|VCC|5V|3V3|3\.3V|POWER)\.\d+$/i, '$1');
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|