- test/classroom.ts:45 个虚拟学生走一遍登录、运行、自测、提交,统计每个环节等多久 - 评分并发可配置(SIM_GRADE_CONCURRENCY,默认 16),提交队列同时评的份数跟着走; 2 核实测项目 8 任务 3 全班自测从约 4 分钟降到约 1.5 分钟 - 检查的默认等待 2 秒→5 秒,机器忙时不再误判正确的程序 - 删除学生时删除他的数据库(编号会重新用给新学生) - deploy/backup.sh、deploy/restore.sh:手动备份到宿主机 backups/,恢复前先备份 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
367 lines
19 KiB
TypeScript
367 lines
19 KiB
TypeScript
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
|
||
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;全站同时跑 config.gradeConcurrency 个检查,其余排队。
|
||
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
|
||
import { rm } from 'node:fs/promises'
|
||
import { dirname } from 'node:path'
|
||
import { config } from '../config'
|
||
import { compileService } from '../compile-service'
|
||
import { gradingDatabaseDir } from '../databases'
|
||
import type { Check, Expectation, Step } from './checks'
|
||
import { ProgramDriver } from './driver'
|
||
|
||
/** 点数据表格的单元格前,最多等多久让那一行出现(程序可能还在查询数据库、刷新表格) */
|
||
const GRID_WAIT = 5000
|
||
/** 操作控件前最多等多久让它出现 */
|
||
const CONTROL_WAIT = 3000
|
||
/** 程序启动后多久内应该显示出窗体 */
|
||
const START_TIMEOUT = 10_000
|
||
|
||
const RELAY_NAMES = ['报警灯', '照明灯', '路灯', '插座 1', '插座 2']
|
||
const ZIGBEE_NAMES = ['ZigBee 节点 0', '照明灯 1']
|
||
/** 摄像头云台开始时的位置(见 IPCameraHelper.cs) */
|
||
const CAMERA_START = { x: 3, y: 1 }
|
||
const CAMERA_WORDS = { on: '开启(传送画面)', off: '关闭', up: '向上转', down: '向下转', left: '向左转', right: '向右转' }
|
||
|
||
// ---------- 并发限制 ----------
|
||
let running = 0
|
||
const waiting: (() => void)[] = []
|
||
|
||
async function withSlot<T>(task: () => Promise<T>): Promise<T> {
|
||
if (running >= config.gradeConcurrency) await new Promise<void>((resolve) => waiting.push(resolve))
|
||
running++
|
||
try {
|
||
return await task()
|
||
} finally {
|
||
running--
|
||
waiting.shift()?.()
|
||
}
|
||
}
|
||
|
||
/** 给学生工程评分;dir 是已写好源文件的工程目录,sampleId 决定用哪个实验的预置数据库(每个检查项一份) */
|
||
export async function grade(dir: string, checks: Check[], sampleId?: string): 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, sampleId))))
|
||
return { compiled: true, results, ms: Date.now() - start }
|
||
}
|
||
|
||
class Failure extends Error {}
|
||
|
||
async function runCheck(dir: string, check: Check, sampleId?: string): Promise<CheckResult> {
|
||
const driver = new ProgramDriver()
|
||
const dbDir = sampleId ? await gradingDatabaseDir(sampleId) : undefined
|
||
try {
|
||
driver.start(dir, dbDir)
|
||
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) {
|
||
// 程序抛了异常时,它才是不通过的根本原因(异常消息可能比失败的检查晚到一点)
|
||
const message = driver.exceptions[0] ? `程序出错:${driver.exceptions[0]}` : error.message
|
||
return { title: check.title, passed: false, message }
|
||
}
|
||
throw error
|
||
} finally {
|
||
driver.stop()
|
||
if (dbDir) await rm(dbDir, { recursive: true, force: true })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 素材库图片的编号:运行器按图片内容的 SHA-1 给图片编号(Image.SimKey),
|
||
* 所以拿素材文件算出的编号和控件属性里的编号相同,就是显示了这张素材。
|
||
*/
|
||
const galleryKeys = new Map<string, string | null>()
|
||
|
||
async function galleryKey(name: string): Promise<string | null> {
|
||
if (!galleryKeys.has(name)) {
|
||
const file = Bun.file(`${dirname(config.runnerDll)}/Resources/gallery/${name}.png`)
|
||
const key = (await file.exists()) ? new Bun.CryptoHasher('sha1').update(await file.arrayBuffer()).digest('hex') : null
|
||
galleryKeys.set(name, key)
|
||
}
|
||
return galleryKeys.get(name)!
|
||
}
|
||
|
||
function need(driver: ProgramDriver, name: string): ControlNode {
|
||
const node = driver.find(name)
|
||
if (!node) throw new Failure(`找不到名为 ${name} 的控件(书上规定的控件名,注意大小写)`)
|
||
return node
|
||
}
|
||
|
||
/** 操作控件前先等它出现:上一步可能刚打开窗体(ShowDialog 前要在 Load 里查数据库,机器忙时会晚一点) */
|
||
async function needSoon(driver: ProgramDriver, name: string): Promise<ControlNode> {
|
||
await driver.waitFor(() => driver.find(name) !== undefined, CONTROL_WAIT)
|
||
return need(driver, name)
|
||
}
|
||
|
||
async function runStep(driver: ProgramDriver, step: Step) {
|
||
switch (step.kind) {
|
||
case 'click': {
|
||
const node = await needSoon(driver, step.control)
|
||
if (node.props.Enabled === false) throw new Failure(`${step.control} 是灰色的(Enabled 为 false),点不了`)
|
||
// 和浏览器一样:图片框先收到鼠标按下、松开,再收到单击(教材项目 8 的方向键在按下、松开时转动摄像机)
|
||
if (node.type === 'PictureBox') {
|
||
driver.act(node, { action: 'mouseDown', button: 'left', x: 1, y: 1 })
|
||
driver.act(node, { action: 'mouseUp', button: 'left', x: 1, y: 1 })
|
||
}
|
||
driver.act(node, { action: 'click' })
|
||
await driver.settle()
|
||
break
|
||
}
|
||
case 'text':
|
||
driver.act(await needSoon(driver, step.control), { action: 'text', value: step.value })
|
||
await driver.settle()
|
||
break
|
||
case 'select': {
|
||
const node = await needSoon(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 'cell':
|
||
case 'edit': {
|
||
const node = await needSoon(driver, step.control)
|
||
if (node.type !== 'DataGridView') throw new Failure(`${step.control} 不是数据表格`)
|
||
// 上一步(如保存后重新查询)可能还在刷新表格:等这一行出现
|
||
await driver.waitFor(() => gridRows(need(driver, step.control)).length > step.row, GRID_WAIT)
|
||
const rows = gridRows(node)
|
||
if (step.row >= rows.length) throw new Failure(`数据表格 ${step.control} 里没有第 ${step.row + 1} 行(现在有 ${rows.length} 行数据)`)
|
||
const column = typeof step.column === 'number' ? step.column : gridColumns(node).findIndex((c) => c.header === step.column || c.name === step.column)
|
||
if (column < 0) throw new Failure(`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${gridColumns(node).map((c) => c.header).join('、') || '没有'})`)
|
||
driver.act(node, { action: 'cellClick', row: step.row, column })
|
||
if (step.kind === 'edit') driver.act(node, { action: 'cellEdit', row: step.row, column, value: step.value })
|
||
await driver.settle()
|
||
break
|
||
}
|
||
case 'pick': {
|
||
const node = await needSoon(driver, step.control)
|
||
if (node.type !== 'DateTimePicker') throw new Failure(`${step.control} 不是日期时间选择器`)
|
||
// 只给日期时:时间取中午,查询"某天到某天"时不受时分影响
|
||
const [date, time = '12:00:00'] = step.value.split(' ')
|
||
driver.act(node, { action: 'pick', value: `${date}T${time.length === 5 ? `${time}:00` : time}` })
|
||
await driver.settle()
|
||
break
|
||
}
|
||
case 'board':
|
||
driver.setBoard(step)
|
||
await driver.settle()
|
||
break
|
||
case 'clock':
|
||
driver.setClock(step.time, step.speed)
|
||
await driver.settle()
|
||
break
|
||
case 'wait':
|
||
await Bun.sleep(step.ms)
|
||
break
|
||
case 'expect':
|
||
await expectStep(driver, step)
|
||
break
|
||
}
|
||
}
|
||
|
||
interface GridColumn {
|
||
name: string
|
||
header: string
|
||
}
|
||
|
||
function gridColumns(node: ControlNode): GridColumn[] {
|
||
return (node.props.Columns as GridColumn[] | undefined) ?? []
|
||
}
|
||
|
||
/** 数据表格的数据行(不含最下面供新增的空行),每行按列序号排列的显示文字 */
|
||
function gridRows(node: ControlNode): string[][] {
|
||
const rows = (node.props.Rows as { items: { cells: unknown[]; isNew: boolean }[] } | undefined)?.items ?? []
|
||
return rows.filter((r) => !r.isNew).map((r) => r.cells.map((c) => (c === true ? 'True' : c === false ? 'False' : String(c ?? ''))))
|
||
}
|
||
|
||
function rowMatches(node: ControlNode, cells: Record<string, string>) {
|
||
const columns = gridColumns(node)
|
||
const index = Object.keys(cells).map((key) => columns.findIndex((c) => c.header === key || c.name === key))
|
||
if (index.some((i) => i < 0)) return false
|
||
return gridRows(node).some((row) => Object.values(cells).every((value, i) => row[index[i]] === value))
|
||
}
|
||
|
||
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 = step.timed ? `${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
|
||
}
|
||
|
||
let key: string | undefined
|
||
if (e.kind === 'image' && e.name) {
|
||
const found = await galleryKey(e.name)
|
||
if (!found) throw new Error(`评分规则写错了:素材库里没有图片 ${e.name}`)
|
||
key = found
|
||
}
|
||
const ok = await driver.waitFor(() => satisfied(driver, e, key), within)
|
||
if (!ok) throw new Failure(describeFailure(driver, e, step.desc, wait))
|
||
}
|
||
|
||
/** 列表框的 Text 只是选中的那一项,检查"包含"时看所有列表项 */
|
||
function listItems(node: ControlNode | undefined): string[] | undefined {
|
||
return node?.type === 'ListBox' ? ((node.props.Items as unknown[] | undefined) ?? []).map(String) : undefined
|
||
}
|
||
|
||
/** 控件属性里的图片({ key, width, height }) */
|
||
const imageKey = (value: unknown) => (value as { key?: string } | null | undefined)?.key
|
||
|
||
function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>, imageName?: string): boolean {
|
||
const props = (name: string) => driver.find(name)?.props
|
||
switch (e.kind) {
|
||
case 'prop': {
|
||
if (e.prop !== 'Visible') return props(e.control)?.[e.prop] === e.value
|
||
const node = driver.find(e.control)
|
||
return node !== undefined && driver.isVisible(node) === e.value
|
||
}
|
||
case 'contains': {
|
||
const items = listItems(driver.find(e.control))
|
||
return items ? items.some((item) => item.includes(e.text)) : 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)
|
||
case 'image': {
|
||
const key = imageKey(props(e.control)?.[e.prop])
|
||
return imageName ? key === imageName : Boolean(key)
|
||
}
|
||
case 'window':
|
||
return driver.forms.some((f) => f.name === e.name) === e.open
|
||
case 'exited':
|
||
return driver.exited
|
||
case 'chart': {
|
||
const series = chartSeries(driver.find(e.control))
|
||
return series.length > 0 && series.every((s) => s.points.length >= e.points)
|
||
}
|
||
case 'camera': {
|
||
const { on, x, y } = driver.board.state.camera
|
||
return { on, off: !on, up: y < CAMERA_START.y, down: y > CAMERA_START.y, left: x < CAMERA_START.x, right: x > CAMERA_START.x }[e.state]
|
||
}
|
||
case 'savedImage':
|
||
return driver.savedImages.some((path) => path.includes(e.path))
|
||
case 'media':
|
||
return driver.played.includes(e.name)
|
||
case 'row': {
|
||
const node = driver.find(e.control)
|
||
return node !== undefined && rowMatches(node, e.cells) === e.present
|
||
}
|
||
}
|
||
}
|
||
|
||
function chartSeries(node: ControlNode | undefined): { name: string; points: unknown[] }[] {
|
||
return (node?.props.Chart as { series?: { name: string; points: unknown[] }[] } | undefined)?.series ?? []
|
||
}
|
||
|
||
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 === 'Visible') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
|
||
if (e.prop === 'Checked') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '被选中' : '没有被选中')},实际${actual ? '被选中' : '没有被选中'}`
|
||
if (e.prop === 'IsOn') return `期望${prefix}窗体上的 ${e.control} ${desc ?? (e.value ? '亮' : '灭')},实际是${actual ? '亮' : '灭'}的`
|
||
if (e.prop === 'Value') return `期望${prefix} ${e.control} ${desc ?? `的值是 ${String(e.value)}`},实际是 ${String(actual)}`
|
||
return `期望${prefix} ${e.control} 的 ${e.prop} 是 ${String(e.value)},实际是 ${String(actual)}`
|
||
}
|
||
case 'contains': {
|
||
const node = need(driver, e.control)
|
||
const items = listItems(node)
|
||
if (items) return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
|
||
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(node.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())}`
|
||
case 'image': {
|
||
const what = e.prop === 'Image' ? '图片' : '背景图片'
|
||
const has = Boolean(imageKey(need(driver, e.control).props[e.prop]))
|
||
if (!e.name) return `期望${prefix} ${e.control} ${desc ?? `显示${what}`},实际没有${what}`
|
||
return `期望${prefix} ${e.control} ${desc ?? `的${what}是 ${e.name}`},实际${has ? '是别的图片' : `没有${what}`}`
|
||
}
|
||
case 'window':
|
||
return `期望${prefix}${desc ?? (e.open ? `打开 ${e.name} 窗体` : `关闭 ${e.name} 窗体`)},实际${e.open ? '没有打开' : '还开着'}(现在打开的窗体:${driver.forms.map((f) => f.name).join('、') || '没有'})`
|
||
case 'exited':
|
||
return `期望${prefix}${desc ?? '程序退出'},实际程序还在运行(打开的窗体:${driver.forms.map((f) => f.name).join('、') || '没有'})`
|
||
case 'chart': {
|
||
const series = chartSeries(driver.find(e.control))
|
||
const actual = series.length ? series.map((s) => `${s.name} ${s.points.length} 个`).join('、') : '没有数据'
|
||
return `期望${prefix}图表 ${e.control} ${desc ?? `每条曲线至少有 ${e.points} 个数据点`},实际${actual}`
|
||
}
|
||
case 'camera':
|
||
return `期望${prefix}网络摄像机${desc ?? CAMERA_WORDS[e.state]},实际${driver.board.state.camera.on ? '开着' : '没有开启'}${e.state === 'on' || e.state === 'off' ? '' : '、云台没有朝这个方向转'}`
|
||
case 'savedImage': {
|
||
const saved = driver.savedImages.map((p) => `"${p}"`).join('、')
|
||
return `期望${prefix}${desc ?? `保存图片到"${e.path}"`},${saved ? `实际保存的是 ${saved}` : '实际没有保存图片'}`
|
||
}
|
||
case 'media':
|
||
return `期望${prefix}${desc ?? `播放 ${e.name}`},${driver.played.length ? `实际播放的是 ${driver.played.join('、')}` : '实际没有播放声音'}`
|
||
case 'row': {
|
||
const node = need(driver, e.control)
|
||
const wanted = Object.entries(e.cells).map(([k, v]) => `${k}=${v}`).join(',')
|
||
const columns = gridColumns(node)
|
||
const missing = Object.keys(e.cells).filter((key) => !columns.some((c) => c.header === key || c.name === key))
|
||
if (missing.length) return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
|
||
const rows = gridRows(node)
|
||
const shown = rows.slice(0, 5).map((r) => r.join(' | ')).join(';')
|
||
return `期望${prefix}数据表格 ${e.control} ${desc ?? (e.present ? `有一行 ${wanted}` : `没有 ${wanted} 的行`)},实际表格里${rows.length ? `是:${shown}${rows.length > 5 ? ' ……' : ''}` : '没有数据'}`
|
||
}
|
||
}
|
||
}
|