- 作业:老师选实验、起点(界面已做好 / 空白工程)、截止时间、每项分值(自动评分项 + 老师评分项); 起点工程由设计器引擎从参考答案生成(去掉事件绑定和事件处理方法),布置时保存起点和评分规则快照 - 提交:后台排队评分(全站同时最多评 3 份、跑 6 个检查项),重启后继续评;评分中不能再交,截止后不能交 - 学生:"我的作业"列表;实验页的作业模式(作业名、截止时间、提交、得分),评分结果弹出逐项显示 - 老师:班级管理页加"作业"标签页,布置、修改、删除作业,查看全班提交情况;改分值后重新算分 - 串口列表为空时提示在 Load 或 Click 事件里填充 - 测试:作业接口(可见范围、排队评分、部分得分、改分值、截止、删除);10 个示例两种起点都能编译 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
8.5 KiB
TypeScript
189 lines
8.5 KiB
TypeScript
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
|
||
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。
|
||
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
|
||
import { compileService } from '../compile-service'
|
||
import type { Check, Expectation, Step } from './checks'
|
||
import { ProgramDriver } from './driver'
|
||
|
||
const GRADE_CONCURRENCY = 6
|
||
/** 程序启动后多久内应该显示出窗体 */
|
||
const START_TIMEOUT = 10_000
|
||
|
||
const RELAY_NAMES = ['报警灯', '照明灯', '路灯', '插座 1', '插座 2']
|
||
const ZIGBEE_NAMES = ['ZigBee 节点 0', '照明灯 1']
|
||
|
||
// ---------- 并发限制 ----------
|
||
let running = 0
|
||
const waiting: (() => void)[] = []
|
||
|
||
async function withSlot<T>(task: () => Promise<T>): Promise<T> {
|
||
if (running >= GRADE_CONCURRENCY) await new Promise<void>((resolve) => waiting.push(resolve))
|
||
running++
|
||
try {
|
||
return await task()
|
||
} finally {
|
||
running--
|
||
waiting.shift()?.()
|
||
}
|
||
}
|
||
|
||
/** 给学生工程评分;dir 是已写好源文件的工程目录 */
|
||
export async function grade(dir: string, checks: Check[]): Promise<GradeResult> {
|
||
const start = Date.now()
|
||
const compiled = await compileService.compile(dir)
|
||
if (!compiled.ok) {
|
||
return {
|
||
compiled: false,
|
||
results: checks.map((c) => ({ title: c.title, passed: false, message: '编译没有通过,先改正编译错误' })),
|
||
ms: Date.now() - start,
|
||
}
|
||
}
|
||
const results = await Promise.all(checks.map((check) => withSlot(() => runCheck(dir, check))))
|
||
return { compiled: true, results, ms: Date.now() - start }
|
||
}
|
||
|
||
class Failure extends Error {}
|
||
|
||
async function runCheck(dir: string, check: Check): Promise<CheckResult> {
|
||
const driver = new ProgramDriver()
|
||
try {
|
||
driver.start(dir)
|
||
if (!(await driver.waitFor(() => driver.forms.length > 0 || driver.exited, START_TIMEOUT)) || driver.forms.length === 0) {
|
||
throw new Failure(driver.exceptions[0] ? `程序出错:${driver.exceptions[0]}` : '程序启动后没有显示窗体')
|
||
}
|
||
for (const step of check.steps) {
|
||
await runStep(driver, step)
|
||
if (driver.exceptions.length > 0) throw new Failure(`程序出错:${driver.exceptions[0]}`)
|
||
}
|
||
return { title: check.title, passed: true }
|
||
} catch (error) {
|
||
if (error instanceof Failure) return { title: check.title, passed: false, message: error.message }
|
||
throw error
|
||
} finally {
|
||
driver.stop()
|
||
}
|
||
}
|
||
|
||
function need(driver: ProgramDriver, name: string): ControlNode {
|
||
const node = driver.find(name)
|
||
if (!node) throw new Failure(`找不到名为 ${name} 的控件(书上规定的控件名,注意大小写)`)
|
||
return node
|
||
}
|
||
|
||
async function runStep(driver: ProgramDriver, step: Step) {
|
||
switch (step.kind) {
|
||
case 'click': {
|
||
const node = need(driver, step.control)
|
||
if (node.props.Enabled === false) throw new Failure(`${step.control} 是灰色的(Enabled 为 false),点不了`)
|
||
driver.act(node, { action: 'click' })
|
||
await driver.settle()
|
||
break
|
||
}
|
||
case 'text':
|
||
driver.act(need(driver, step.control), { action: 'text', value: step.value })
|
||
await driver.settle()
|
||
break
|
||
case 'select': {
|
||
const node = need(driver, step.control)
|
||
if (node.type !== 'ComboBox') throw new Failure(`${step.control} 不是下拉框`)
|
||
// 和浏览器一样先展开(教材在下拉框的 Click 里填充串口列表),再选
|
||
driver.act(node, { action: 'dropDown' })
|
||
await driver.settle()
|
||
const items = ((node.props.Items as unknown[] | undefined) ?? []).map(String)
|
||
const index = items.indexOf(step.item)
|
||
if (index < 0) {
|
||
// 串口列表是空的:最常见的原因是没有填充
|
||
const hint = items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
|
||
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})${hint}`)
|
||
}
|
||
driver.act(node, { action: 'select', index })
|
||
await driver.settle()
|
||
break
|
||
}
|
||
case 'board':
|
||
driver.setBoard(step)
|
||
await driver.settle()
|
||
break
|
||
case 'wait':
|
||
await Bun.sleep(step.ms)
|
||
break
|
||
case 'expect':
|
||
await expectStep(driver, step)
|
||
break
|
||
}
|
||
}
|
||
|
||
const seconds = (ms: number) => `${Math.round(ms / 100) / 10} 秒`
|
||
const quote = (value: unknown) => (value === undefined || value === null || value === '' ? '空的' : `"${String(value)}"`)
|
||
|
||
async function expectStep(driver: ProgramDriver, step: Extract<Step, { kind: 'expect' }>) {
|
||
const { expect: e, within } = step
|
||
const wait = within > 2000 ? `${seconds(within)}内` : ''
|
||
|
||
if (e.kind === 'unchanged') {
|
||
const node = need(driver, e.control)
|
||
const before = node.props.Text
|
||
await Bun.sleep(e.ms)
|
||
if (node.props.Text !== before) {
|
||
throw new Failure(`期望 ${e.control} ${step.desc ?? `在 ${seconds(e.ms)}内保持不变`},实际从${quote(before)}变成了${quote(node.props.Text)}`)
|
||
}
|
||
return
|
||
}
|
||
|
||
const ok = await driver.waitFor(() => satisfied(driver, e), within)
|
||
if (!ok) throw new Failure(describeFailure(driver, e, step.desc, wait))
|
||
}
|
||
|
||
function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>): boolean {
|
||
const props = (name: string) => driver.find(name)?.props
|
||
switch (e.kind) {
|
||
case 'prop':
|
||
return props(e.control)?.[e.prop] === e.value
|
||
case 'contains':
|
||
return String(props(e.control)?.Text ?? '').includes(e.text)
|
||
case 'matches':
|
||
return e.pattern.test(String(props(e.control)?.Text ?? ''))
|
||
case 'led':
|
||
return driver.board.state.ledText.includes(e.text)
|
||
case 'relay':
|
||
return driver.board.state.relays[e.index] === e.on
|
||
case 'zigbee':
|
||
return driver.board.state.zigbee[e.index] === e.on
|
||
case 'messageBox':
|
||
return driver.messageBoxes.some((box) => box.text.includes(e.text))
|
||
case 'console':
|
||
return driver.console.includes(e.text)
|
||
}
|
||
}
|
||
|
||
function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>, desc: string | undefined, wait: string): string {
|
||
const prefix = wait ? `${wait}` : ''
|
||
const onOff = (on: boolean) => (on ? '打开' : '关闭')
|
||
switch (e.kind) {
|
||
case 'prop': {
|
||
const actual = need(driver, e.control).props[e.prop]
|
||
if (e.prop === 'Text' || e.prop === 'LedText') return `期望${prefix} ${e.control} ${desc ?? `显示"${e.value}"`},实际是${quote(actual)}`
|
||
if (e.prop === 'Enabled') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
|
||
if (e.prop === 'Checked') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '被选中' : '没有被选中')},实际${actual ? '被选中' : '没有被选中'}`
|
||
if (e.prop === 'IsOn') return `期望${prefix}窗体上的 ${e.control} ${desc ?? (e.value ? '亮' : '灭')},实际是${actual ? '亮' : '灭'}的`
|
||
return `期望${prefix} ${e.control} 的 ${e.prop} 是 ${String(e.value)},实际是 ${String(actual)}`
|
||
}
|
||
case 'contains':
|
||
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(need(driver, e.control).props.Text)}`
|
||
case 'matches':
|
||
return `期望${prefix} ${e.control} ${desc ?? `的文字符合 ${e.pattern}`},实际是${quote(need(driver, e.control).props.Text)}`
|
||
case 'led':
|
||
return `期望${prefix}实训台 LED 屏${desc ?? `显示"${e.text}"`},实际${driver.board.state.ledText ? `显示"${driver.board.state.ledText}"` : '什么也没显示'}`
|
||
case 'relay':
|
||
return `期望${prefix}实训台的${RELAY_NAMES[e.index] ?? `DO${e.index}`}(ADAM-4150 DO${e.index})${desc ?? onOff(e.on)},实际是${onOff(driver.board.state.relays[e.index])}的`
|
||
case 'zigbee':
|
||
return `期望${prefix}实训台的${ZIGBEE_NAMES[e.index] ?? `ZigBee 继电器 ${e.index}`}${desc ?? onOff(e.on)},实际是${onOff(driver.board.state.zigbee[e.index])}的`
|
||
case 'messageBox': {
|
||
const shown = driver.messageBoxes.map((b) => `"${b.text}"`).join('、')
|
||
return `期望${prefix}弹出${desc ?? `包含"${e.text}"的提示框`},${shown ? `实际弹出的是 ${shown}` : '实际没有弹出提示框'}`
|
||
}
|
||
case 'console':
|
||
return `期望${prefix}控制台${desc ?? `输出"${e.text}"`},实际输出${quote(driver.console.trim())}`
|
||
}
|
||
}
|