Files
IOT_SIM/server/src/grading/driver.ts
T
xuyueandClaude Opus 5.5 4ed9450c02
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
修复:手动改过输入框后,程序再把文字设回原值时输入框不刷新
浏览器把用户输入发给运行器时没有同步更新自己记下的 Text,运行器再设成同样的值时,
属性看起来没变,输入框不会刷新。现在发送输入时同步记下(文本框、可输入的下拉框);
评分驱动做同样处理。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:41:55 +08:00

174 lines
5.8 KiB
TypeScript

// 评分时驱动一个学生程序:在沙箱里启动,像浏览器一样维护控件树,按控件名操作控件、调节实训台。
// 提示框自动点第一个按钮("确定"/"是"),免得程序一直等在 MessageBox.Show 上。
import type { Subprocess } from 'bun'
import type { ControlNode, RunnerInput, RunnerMessage, UserAction } from '@iot/shared'
import { Board } from '../board'
import { readLines } from '../runner-process'
import { runCommand } from '../sandbox'
import type { Step } from './checks'
type BoardStep = Extract<Step, { kind: 'board' }>
export class ProgramDriver {
readonly board = new Board()
/** 当前显示的窗体,按显示先后排列 */
readonly forms: ControlNode[] = []
private readonly nodes = new Map<string, ControlNode>()
private readonly parents = new Map<string, ControlNode>()
readonly messageBoxes: { text: string; caption: string }[] = []
readonly exceptions: string[] = []
console = ''
exited = false
private proc?: Subprocess<'pipe', 'pipe', 'pipe'>
private listeners = new Set<() => void>()
private lastMessageAt = Date.now()
start(projectDir: string) {
const { cmd, env } = runCommand(projectDir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) })
const proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env })
this.proc = proc
void readLines(proc.stdout, (line) => {
try {
this.receive(JSON.parse(line) as RunnerMessage)
} catch {
this.console += `${line}\n`
}
})
void new Response(proc.stderr).text()
void proc.exited.then(() => {
this.exited = true
this.notify()
})
}
stop() {
this.proc?.kill()
}
private send(message: RunnerInput) {
if (this.exited || !this.proc) return
this.proc.stdin.write(`${JSON.stringify(message)}\n`)
this.proc.stdin.flush()
}
private register(node: ControlNode, parent?: ControlNode) {
this.nodes.set(node.id, node)
if (parent) this.parents.set(node.id, parent)
for (const child of node.children) this.register(child, node)
}
private unregister(node: ControlNode) {
this.nodes.delete(node.id)
this.parents.delete(node.id)
for (const child of node.children) this.unregister(child)
}
private receive(message: RunnerMessage) {
this.lastMessageAt = Date.now()
switch (message.type) {
case 'formShown':
this.forms.push(message.form)
this.register(message.form)
break
case 'formClosed': {
const index = this.forms.findIndex((f) => f.id === message.formId)
if (index >= 0) this.unregister(this.forms.splice(index, 1)[0])
break
}
case 'patch':
for (const change of message.changes) {
const node = this.nodes.get(change.id)
if (node) node.props[change.prop] = change.value
}
break
case 'controlAdded': {
const parent = this.nodes.get(message.parentId)
if (!parent) break
parent.children.splice(message.index, 0, message.node)
this.register(message.node, parent)
break
}
case 'controlRemoved': {
const node = this.nodes.get(message.id)
const parent = this.parents.get(message.id)
if (!node) break
if (parent) parent.children = parent.children.filter((c) => c.id !== node.id)
this.unregister(node)
break
}
case 'messageBox': {
this.messageBoxes.push({ text: message.text, caption: message.caption })
// 自动点第一个按钮
const first = /^(OK|Yes|Retry|Abort)/.exec(message.buttons)?.[1] ?? 'OK'
this.send({ type: 'reply', requestId: message.requestId, result: first })
break
}
case 'device':
this.board.applyDevice(message)
break
case 'console':
this.console += message.text
break
case 'exception':
this.exceptions.push(message.message)
break
}
this.notify()
}
private notify() {
for (const listener of this.listeners) listener()
}
/** 按控件名找控件;多个窗体里有同名控件时取最后显示的窗体里的 */
find(name: string): ControlNode | undefined {
for (const form of [...this.forms].reverse()) {
const found = findIn(form, name)
if (found) return found
}
}
act(node: ControlNode, action: UserAction) {
// 和浏览器一样同步记下输入的文字(运行器不回传)
if (action.action === 'text') node.props.Text = action.value
this.send({ type: 'action', id: node.id, ...action })
}
setBoard(step: BoardStep) {
this.board.applyInput({ type: 'boardInput', analog: step.analog, digital: step.digital })
this.send(this.board.runnerInputs())
}
/** 等到条件成立(立即检查,之后每收到一条消息检查一次),超时返回 false */
waitFor(condition: () => boolean, timeoutMs: number): Promise<boolean> {
if (condition()) return Promise.resolve(true)
return new Promise((resolve) => {
const done = (result: boolean) => {
clearTimeout(timer)
this.listeners.delete(check)
resolve(result)
}
const check = () => {
if (condition()) done(true)
}
const timer = setTimeout(() => done(condition()), timeoutMs)
this.listeners.add(check)
})
}
/** 等程序处理完刚才的操作:从现在起一段时间内没有新消息(最多等 maxMs) */
async settle(quietMs = 150, maxMs = 2000) {
const start = Date.now()
const quietFor = () => Date.now() - Math.max(this.lastMessageAt, start)
while (Date.now() - start < maxMs && quietFor() < quietMs) await Bun.sleep(quietMs - quietFor())
}
}
function findIn(node: ControlNode, name: string): ControlNode | undefined {
if (node.name === name) return node
for (const child of node.children) {
const found = findIn(child, name)
if (found) return found
}
}