From 37fc747b49d634f4053aa0a4270a27a40574d76d Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 22 Oct 2026 18:20:41 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=AF=84=E5=88=86=EF=BC=9A?= =?UTF-8?q?=E8=AF=84=E5=88=86=E8=A7=84=E5=88=99=E5=92=8C=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每个示例的 checks.yaml:书上评价表的功能项转成操作步骤和期望结果(10 个示例共 23 项) - 引擎:编译一次,每项在沙箱里单独启动一次程序,按控件名操作、调节实训台、核对结果; 提示框自动点第一个按钮;全站同时最多跑 2 项 - 不通过时写明期望和实际,如"期望6 秒内 txt_even 的文字包含"发现入侵",实际是"正常""; 找不到书上规定的控件名时直接指出 - 规则写错时指出第几项第几步 - 测试:参考答案全部通过;编译错误、控件改名、判断写反、忘停定时器、接错 DO 口各自报出对应原因 Co-Authored-By: Claude Opus 5.5 --- samples/p1-t1-登录/checks.yaml | 29 +++ samples/p1-t2-面向对象特性/checks.yaml | 18 ++ samples/p2-t1-LED显示屏/checks.yaml | 16 ++ samples/p2-t2-模拟量采集/checks.yaml | 20 ++ samples/p2-t3-LED推送模拟量/checks.yaml | 22 +++ samples/p3-t1-ZigBee路灯/checks.yaml | 18 ++ samples/p3-t2-人体红外ZigBee联动/checks.yaml | 23 +++ samples/p4-t1-红外对射/checks.yaml | 26 +++ samples/p4-t3-报警灯/checks.yaml | 18 ++ samples/p5-t1-火焰/checks.yaml | 26 +++ server/src/grading/checks.ts | 128 +++++++++++++ server/src/grading/driver.ts | 171 +++++++++++++++++ server/src/grading/grader.ts | 186 +++++++++++++++++++ server/test/grading.test.ts | 104 +++++++++++ shared/grading.ts | 17 ++ shared/protocol.ts | 2 + 16 files changed, 824 insertions(+) create mode 100644 samples/p1-t1-登录/checks.yaml create mode 100644 samples/p1-t2-面向对象特性/checks.yaml create mode 100644 samples/p2-t1-LED显示屏/checks.yaml create mode 100644 samples/p2-t2-模拟量采集/checks.yaml create mode 100644 samples/p2-t3-LED推送模拟量/checks.yaml create mode 100644 samples/p3-t1-ZigBee路灯/checks.yaml create mode 100644 samples/p3-t2-人体红外ZigBee联动/checks.yaml create mode 100644 samples/p4-t1-红外对射/checks.yaml create mode 100644 samples/p4-t3-报警灯/checks.yaml create mode 100644 samples/p5-t1-火焰/checks.yaml create mode 100644 server/src/grading/checks.ts create mode 100644 server/src/grading/driver.ts create mode 100644 server/src/grading/grader.ts create mode 100644 server/test/grading.test.ts create mode 100644 shared/grading.ts diff --git a/samples/p1-t1-登录/checks.yaml b/samples/p1-t1-登录/checks.yaml new file mode 100644 index 0000000..1742ea5 --- /dev/null +++ b/samples/p1-t1-登录/checks.yaml @@ -0,0 +1,29 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 增加用户后能用新账号登录 + steps: + - click: btnAdd + - text: { control: txtNewUser1, value: zhangsan } + - text: { control: txtPass1, value: "123" } + - text: { control: txtPass2, value: "123" } + - click: btnOk + - text: { control: txtUserName, value: zhangsan } + - text: { control: txtPassword, value: "123" } + - click: btnOk + - expect: { messageBox: 登录成功 } + +- title: 用户名或密码不对时提示错误 + steps: + - text: { control: txtUserName, value: nobody } + - text: { control: txtPassword, value: wrong } + - click: btnOk + - expect: { messageBox: 无效的密码或用户名 } + +- title: 两次输入的密码不一致时不能增加用户 + steps: + - click: btnAdd + - text: { control: txtNewUser1, value: lisi } + - text: { control: txtPass1, value: "123" } + - text: { control: txtPass2, value: "456" } + - click: btnOk + - expect: { messageBox: 用户名或密码错误 } diff --git a/samples/p1-t2-面向对象特性/checks.yaml b/samples/p1-t2-面向对象特性/checks.yaml new file mode 100644 index 0000000..1d58226 --- /dev/null +++ b/samples/p1-t2-面向对象特性/checks.yaml @@ -0,0 +1,18 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 计算器能正确计算(装箱与拆箱) + steps: + - text: { control: txtNum1, value: "7" } + - text: { control: txtNum2, value: "2" } + - select: { control: cmboxType, item: "+" } + - click: btnCalculate + - expect: { control: txtResult, text: "9" } + - select: { control: cmboxType, item: "/" } + - click: btnCalculate + - expect: { control: txtResult, text: "3.5" } + +- title: 多态:不同国家的人说不同的语言 + steps: + - click: btnDisplay + - expect: { control: txtDispaly, contains: 张三说的是汉语 } + - expect: { control: txtDispaly, contains: Mary说的是英语 } diff --git a/samples/p2-t1-LED显示屏/checks.yaml b/samples/p2-t1-LED显示屏/checks.yaml new file mode 100644 index 0000000..34f386e --- /dev/null +++ b/samples/p2-t1-LED显示屏/checks.yaml @@ -0,0 +1,16 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: LED 屏能正常显示推送的文字 + steps: + - select: { control: com_Port, item: COM5 } + - click: btn_Open + - text: { control: txt_SendText, value: 欢迎光临 } + - click: btn_Send + - expect: { led: 欢迎光临 } + - expect: { control: led, ledText: 欢迎光临, desc: 窗体上的 LED 控件也显示"欢迎光临" } + +- title: 没有打开串口时推送,弹出出错提示 + steps: + - text: { control: txt_SendText, value: 欢迎光临 } + - click: btn_Send + - expect: { messageBox: 发送失败 } diff --git a/samples/p2-t2-模拟量采集/checks.yaml b/samples/p2-t2-模拟量采集/checks.yaml new file mode 100644 index 0000000..be3588f --- /dev/null +++ b/samples/p2-t2-模拟量采集/checks.yaml @@ -0,0 +1,20 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 环境值能正确获取和显示 + steps: + - select: { control: com_port, item: COM2 } + - click: btn_Open + - expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" } + - click: btn_get + - expect: { control: txt_temperature, text: "25" } + - expect: { control: txt_humidity, text: "60" } + - expect: { control: txt_light, text: "500" } + +- title: 环境变化后能获取到新的数值 + steps: + - select: { control: com_port, item: COM2 } + - click: btn_Open + - board: { temperature: 31, light: 300 } + - click: btn_get + - expect: { control: txt_temperature, text: "31" } + - expect: { control: txt_light, text: "300" } diff --git a/samples/p2-t3-LED推送模拟量/checks.yaml b/samples/p2-t3-LED推送模拟量/checks.yaml new file mode 100644 index 0000000..694a5a5 --- /dev/null +++ b/samples/p2-t3-LED推送模拟量/checks.yaml @@ -0,0 +1,22 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 环境值能正确推送到 LED 屏 + steps: + - select: { control: com_adamPort, item: COM2 } + - select: { control: com_ledPort, item: COM5 } + - click: btn_open + - click: btn_get + - expect: { control: txt_temperature, text: "25" } + - expect: { led: "25", desc: 显示温度 25 } + - expect: { led: "60", desc: 显示湿度 60 } + - expect: { led: "500", desc: 显示光照度 500 } + +- title: 能推送最新的环境信息 + steps: + - select: { control: com_adamPort, item: COM2 } + - select: { control: com_ledPort, item: COM5 } + - click: btn_open + - click: btn_get + - board: { temperature: 31 } + - click: btn_get + - expect: { led: "31", desc: 显示最新的温度 31 } diff --git a/samples/p3-t1-ZigBee路灯/checks.yaml b/samples/p3-t1-ZigBee路灯/checks.yaml new file mode 100644 index 0000000..66ae48a --- /dev/null +++ b/samples/p3-t1-ZigBee路灯/checks.yaml @@ -0,0 +1,18 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 能通过 ZigBee 打开路灯 + steps: + - select: { control: com_port, item: COM4 } + - click: btn_open + - click: rb_Open + - expect: { zigbee: 1, on: true } + - expect: { control: lamp1, on: true } + +- title: 能通过 ZigBee 关闭路灯 + steps: + - select: { control: com_port, item: COM4 } + - click: btn_open + - click: rb_Open + - click: rb_Close + - expect: { zigbee: 1, on: false } + - expect: { control: lamp1, on: false } diff --git a/samples/p3-t2-人体红外ZigBee联动/checks.yaml b/samples/p3-t2-人体红外ZigBee联动/checks.yaml new file mode 100644 index 0000000..ed34c9b --- /dev/null +++ b/samples/p3-t2-人体红外ZigBee联动/checks.yaml @@ -0,0 +1,23 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 能手动开关路灯 + steps: + - select: { control: com_lamp, item: COM4 } + - select: { control: com_environment, item: COM3 } + - click: btn_open + - click: btn_lampOpen + - expect: { zigbee: 1, on: true } + - click: btn_lampClose + - expect: { zigbee: 1, on: false } + +- title: 能实时联动路灯(有人亮灯,没人关灯) + steps: + - select: { control: com_lamp, item: COM4 } + - select: { control: com_environment, item: COM3 } + - click: btn_open + - click: ch_bodyInfrared + - expect: { control: btn_lampOpen, enabled: false, desc: 勾选人体开关后"开"按钮不可用 } + - board: { bodyInfrared: true } + - expect: { zigbee: 1, on: true, within: 6s, desc: 有人经过时打开 } + - board: { bodyInfrared: false } + - expect: { zigbee: 1, on: false, within: 6s, desc: 人离开后关闭 } diff --git a/samples/p4-t1-红外对射/checks.yaml b/samples/p4-t1-红外对射/checks.yaml new file mode 100644 index 0000000..122acf9 --- /dev/null +++ b/samples/p4-t1-红外对射/checks.yaml @@ -0,0 +1,26 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 按钮能打开串口、开启定时器,更改按钮文字 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - expect: { control: btn_open, text: 关闭, desc: 按钮文字变成"关闭" } + - expect: { control: txt_time, matches: '\d{1,2}:\d{2}', within: 6s, desc: 显示当前时间 } + +- title: 能实时显示入侵数据 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - board: { infrared: true } + - expect: { control: txt_even, contains: 发现入侵, within: 6s } + - board: { infrared: false } + - expect: { control: txt_even, contains: 正常, within: 6s } + +- title: 再点按钮能关闭串口、停止定时器 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - expect: { control: txt_time, matches: '\d{1,2}:\d{2}', within: 6s, desc: 显示当前时间 } + - click: btn_open + - expect: { control: btn_open, text: 开启, desc: 按钮文字变回"开启" } + - expect: { control: txt_time, unchanged: 6s, desc: 不再更新时间(定时器应已停止) } diff --git a/samples/p4-t3-报警灯/checks.yaml b/samples/p4-t3-报警灯/checks.yaml new file mode 100644 index 0000000..5d11f30 --- /dev/null +++ b/samples/p4-t3-报警灯/checks.yaml @@ -0,0 +1,18 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 能正确打开报警灯 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - click: btn_alarmOpen + - expect: { relay: 0, on: true } + - expect: { control: alarm1, on: true } + +- title: 能正确关闭报警灯 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - click: btn_alarmOpen + - click: btn_alarmClose + - expect: { relay: 0, on: false } + - expect: { control: alarm1, on: false } diff --git a/samples/p5-t1-火焰/checks.yaml b/samples/p5-t1-火焰/checks.yaml new file mode 100644 index 0000000..5acf295 --- /dev/null +++ b/samples/p5-t1-火焰/checks.yaml @@ -0,0 +1,26 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 按钮能打开串口、开启定时器,更改按钮文字 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - expect: { control: btn_open, text: 关闭, desc: 按钮文字变成"关闭" } + - expect: { control: txt_time, matches: '\d{1,2}:\d{2}', within: 7s, desc: 显示当前时间 } + +- title: 能实时显示火焰传感器数据 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - board: { fire: true } + - expect: { control: txt_even, contains: 发现火情, within: 7s } + - board: { fire: false } + - expect: { control: txt_even, contains: 正常, within: 7s } + +- title: 再点按钮能关闭串口、停止定时器 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - expect: { control: txt_time, matches: '\d{1,2}:\d{2}', within: 7s, desc: 显示当前时间 } + - click: btn_open + - expect: { control: btn_open, text: 开启, desc: 按钮文字变回"开启" } + - expect: { control: txt_time, unchanged: 7s, desc: 不再更新时间(定时器应已停止) } diff --git a/server/src/grading/checks.ts b/server/src/grading/checks.ts new file mode 100644 index 0000000..8e26fd7 --- /dev/null +++ b/server/src/grading/checks.ts @@ -0,0 +1,128 @@ +// 评分规则(每个实验的 checks.yaml):书上评价表的功能项,每项是一串操作步骤和期望结果。 +// 规则写错时报出是哪一项、哪一步,方便改。 +import { ANALOG_INFO, type AnalogKey, DIGITAL_CHANNELS, type DigitalKey } from '@iot/shared' + +export interface Check { + title: string + steps: Step[] +} + +export type Step = + | { kind: 'click'; control: string } + | { kind: 'text'; control: string; value: string } + | { kind: 'select'; control: string; item: string } + | { kind: 'board'; analog: Partial>; digital: Partial> } + | { kind: 'wait'; ms: number } + /** desc:给学生看的期望说明,如"显示当前时间",不写就按期望自动生成 */ + | { kind: 'expect'; expect: Expectation; within: number; desc?: string } + +export type Expectation = + /** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,on → IsOn(灯泡、报警灯) */ + | { kind: 'prop'; control: string; prop: string; value: unknown } + | { kind: 'contains'; control: string; text: string } + | { kind: 'matches'; control: string; pattern: RegExp; source: string } + /** 一段时间内控件文字不变(比如定时器已经停止) */ + | { kind: 'unchanged'; control: string; ms: number } + | { kind: 'led'; text: string } + | { kind: 'relay'; index: number; on: boolean } + | { kind: 'zigbee'; index: number; on: boolean } + | { kind: 'messageBox'; text: string } + | { kind: 'console'; text: string } + +/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */ +const DEFAULT_WITHIN = 2000 + +const PROP_KEYS: Record = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', on: 'IsOn' } +const STRING_PROPS = new Set(['Text', 'LedText']) + +/** "6s"、"500ms" 或毫秒数 */ +function duration(value: unknown, where: string): number { + if (typeof value === 'number' && value >= 0) return value + const m = typeof value === 'string' ? /^(\d+(?:\.\d+)?)\s*(ms|s)$/.exec(value.trim()) : null + if (!m) throw new Error(`${where}:时长要写成 6s 或 500ms,现在是 ${JSON.stringify(value)}`) + return Number(m[1]) * (m[2] === 's' ? 1000 : 1) +} + +function str(value: unknown, where: string, what: string): string { + if (typeof value === 'string' && value) return value + if (typeof value === 'number') return String(value) + throw new Error(`${where}:缺少${what}`) +} + +function bool(value: unknown, where: string, what: string): boolean { + if (typeof value === 'boolean') return value + throw new Error(`${where}:${what}要写 true 或 false`) +} + +function parseExpectation(raw: Record, where: string): Expectation { + if ('led' in raw) return { kind: 'led', text: str(raw.led, where, ' LED 屏应显示的文字') } + if ('messageBox' in raw) return { kind: 'messageBox', text: str(raw.messageBox, where, '提示框应包含的文字') } + if ('console' in raw) return { kind: 'console', text: str(raw.console, where, '控制台应输出的文字') } + if ('relay' in raw) return { kind: 'relay', index: Number(raw.relay), on: bool(raw.on, where, 'on') } + if ('zigbee' in raw) return { kind: 'zigbee', index: Number(raw.zigbee), on: bool(raw.on, where, 'on') } + + const control = str(raw.control, where, '控件名 control') + if ('contains' in raw) return { kind: 'contains', control, text: str(raw.contains, where, 'contains') } + if ('matches' in raw) { + const source = str(raw.matches, where, 'matches') + return { kind: 'matches', control, pattern: new RegExp(source), source } + } + if ('unchanged' in raw) return { kind: 'unchanged', control, ms: duration(raw.unchanged, where) } + for (const [key, prop] of Object.entries(PROP_KEYS)) { + if (key in raw) return { kind: 'prop', control, prop, value: STRING_PROPS.has(prop) ? String(raw[key]) : bool(raw[key], where, key) } + } + throw new Error(`${where}:不认识的期望 ${JSON.stringify(raw)}`) +} + +function parseBoard(raw: Record, where: string): Step { + const analog: Partial> = {} + const digital: Partial> = {} + for (const [key, value] of Object.entries(raw)) { + if (key in ANALOG_INFO && typeof value === 'number') analog[key as AnalogKey] = value + else if ((DIGITAL_CHANNELS as readonly string[]).includes(key) && typeof value === 'boolean') digital[key as DigitalKey] = value + else throw new Error(`${where}:实训台没有 ${key},或者值的类型不对(模拟量写数字,开关量写 true / false)`) + } + return { kind: 'board', analog, digital } +} + +function parseStep(raw: unknown, where: string): Step { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${where}:每一步要写成"操作: 内容"`) + const [[kind, value], ...rest] = Object.entries(raw as Record) + if (kind === 'expect' && rest.length === 0) { + const body = value as Record + const { within, desc, ...expectation } = body + return { + kind: 'expect', + expect: parseExpectation(expectation, where), + within: within === undefined ? DEFAULT_WITHIN : duration(within, where), + ...(desc === undefined ? {} : { desc: String(desc) }), + } + } + if (rest.length > 0) throw new Error(`${where}:一步只能写一个操作`) + const obj = (value ?? {}) as Record + switch (kind) { + case 'click': + return { kind, control: str(value, where, '要点击的控件名') } + case 'text': + return { kind, control: str(obj.control, where, '控件名 control'), value: String(obj.value ?? '') } + case 'select': + return { kind, control: str(obj.control, where, '控件名 control'), item: str(obj.item, where, '要选的项 item') } + case 'board': + return parseBoard(obj, where) + case 'wait': + return { kind, ms: duration(value, where) } + default: + throw new Error(`${where}:不认识的操作 ${kind}`) + } +} + +/** 解析 checks.yaml 的内容 */ +export function parseChecks(yaml: string): Check[] { + const raw = Bun.YAML.parse(yaml) + if (!Array.isArray(raw)) throw new Error('评分规则应该是一个列表,每项以"- title:"开头') + return raw.map((item: { title?: unknown; steps?: unknown }, i: number) => { + const title = str(item?.title, `第 ${i + 1} 项`, '名称 title') + if (!Array.isArray(item.steps) || item.steps.length === 0) throw new Error(`第 ${i + 1} 项"${title}":缺少步骤 steps`) + return { title, steps: item.steps.map((step, j) => parseStep(step, `第 ${i + 1} 项"${title}"第 ${j + 1} 步`)) } + }) +} diff --git a/server/src/grading/driver.ts b/server/src/grading/driver.ts new file mode 100644 index 0000000..d43a377 --- /dev/null +++ b/server/src/grading/driver.ts @@ -0,0 +1,171 @@ +// 评分时驱动一个学生程序:在沙箱里启动,像浏览器一样维护控件树,按控件名操作控件、调节实训台。 +// 提示框自动点第一个按钮("确定"/"是"),免得程序一直等在 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 + +export class ProgramDriver { + readonly board = new Board() + /** 当前显示的窗体,按显示先后排列 */ + readonly forms: ControlNode[] = [] + private readonly nodes = new Map() + private readonly parents = new Map() + 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) { + 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 { + 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 + } +} diff --git a/server/src/grading/grader.ts b/server/src/grading/grader.ts new file mode 100644 index 0000000..06b3e75 --- /dev/null +++ b/server/src/grading/grader.ts @@ -0,0 +1,186 @@ +// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。 +// 服务器只有 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 = 2 +/** 程序启动后多久内应该显示出窗体 */ +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(task: () => Promise): Promise { + if (running >= GRADE_CONCURRENCY) await new Promise((resolve) => waiting.push(resolve)) + running++ + try { + return await task() + } finally { + running-- + waiting.shift()?.() + } +} + +/** 给学生工程评分;dir 是已写好源文件的工程目录 */ +export async function grade(dir: string, checks: Check[]): Promise { + 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 { + 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) { + throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})`) + } + 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) { + 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): 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, 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())}` + } +} diff --git a/server/test/grading.test.ts b/server/test/grading.test.ts new file mode 100644 index 0000000..acd8212 --- /dev/null +++ b/server/test/grading.test.ts @@ -0,0 +1,104 @@ +// 自动评分测试:示例的参考答案应该全部通过;改坏的代码在对应的项上不通过,原因写得清楚。 +import { afterAll, describe, expect, test } from 'bun:test' +import { cp, rm } from 'node:fs/promises' +import { resolve } from 'node:path' +import { compileService } from '../src/compile-service' +import { ROOT } from '../src/config' +import { parseChecks } from '../src/grading/checks' +import { grade } from '../src/grading/grader' + +afterAll(() => compileService.stop()) + +const SAMPLES = resolve(ROOT, 'samples') + +async function checksOf(id: string) { + return parseChecks(await Bun.file(resolve(SAMPLES, id, 'checks.yaml')).text()) +} + +/** 复制示例到临时目录;edit 可以改坏某个文件 */ +async function prepare(id: string, name: string, edit?: { file: string; from: string; to: string }) { + const dir = resolve(ROOT, '.runs', `grade-${name}`) + await rm(dir, { recursive: true, force: true }) + await cp(resolve(SAMPLES, id), dir, { recursive: true }) + if (edit) { + const path = resolve(dir, edit.file) + const text = await Bun.file(path).text() + if (!text.includes(edit.from)) throw new Error(`${edit.file} 里没有 ${edit.from}`) + await Bun.write(path, text.replace(edit.from, edit.to)) + } + return dir +} + +describe('参考答案全部通过', async () => { + const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort() + test('每个示例都有评分规则', () => expect(ids.length).toBe(10)) + for (const id of ids) { + test(id, async () => { + const result = await grade(await prepare(id, id), await checksOf(id)) + expect(result.compiled).toBe(true) + expect(result.results.filter((r) => !r.passed)).toEqual([]) + }, 60000) + } +}) + +describe('改坏的代码', () => { + test('编译不通过时所有项都不通过', async () => { + const id = 'p2-t1-LED显示屏' + const dir = await prepare(id, 'compile-error', { file: 'Form1.cs', from: 'led.setLedText(res);', to: 'led.setLedText(res)' }) + const result = await grade(dir, await checksOf(id)) + expect(result.compiled).toBe(false) + expect(result.results.every((r) => !r.passed && r.message === '编译没有通过,先改正编译错误')).toBe(true) + }, 60000) + + test('控件名和书上不一样:提示找不到控件', async () => { + const id = 'p2-t1-LED显示屏' + const dir = await prepare(id, 'rename', { file: 'Form1.cs', from: 'txt_SendText.Text', to: 'textBox1.Text' }) + // 同时把 Designer.cs 里的控件改名,模拟学生没按书上命名 + const designer = resolve(dir, 'Form1.Designer.cs') + await Bun.write(designer, (await Bun.file(designer).text()).replaceAll('txt_SendText', 'textBox1')) + const result = await grade(dir, await checksOf(id)) + expect(result.results[0]).toMatchObject({ passed: false, message: '找不到名为 txt_SendText 的控件(书上规定的控件名,注意大小写)' }) + }, 60000) + + test('判断写反:入侵时显示"正常"', async () => { + const id = 'p4-t1-红外对射' + const dir = await prepare(id, 'reversed', { file: 'Form1.cs', from: 'if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)', to: 'if (!DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)' }) + const result = await grade(dir, await checksOf(id)) + const realtime = result.results.find((r) => r.title === '能实时显示入侵数据')! + expect(realtime.passed).toBe(false) + expect(realtime.message).toBe('期望6 秒内 txt_even 的文字包含"发现入侵",实际是"正常"') + // 开关按钮的两项不受影响 + expect(result.results.filter((r) => r.passed).length).toBe(2) + }, 60000) + + test('关闭时忘了停定时器', async () => { + const id = 'p4-t1-红外对射' + const dir = await prepare(id, 'timer', { file: 'Form1.cs', from: 'timer1.Stop();', to: '' }) + const result = await grade(dir, await checksOf(id)) + const stop = result.results.find((r) => r.title === '再点按钮能关闭串口、停止定时器')! + expect(stop.passed).toBe(false) + expect(stop.message).toStartWith('期望 txt_time 不再更新时间(定时器应已停止),实际从') + }, 60000) + + test('报警灯接错了 DO 口', async () => { + const id = 'p4-t3-报警灯' + const dir = await prepare(id, 'relay', { file: 'Form1.cs', from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);', to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);' }) + const result = await grade(dir, await checksOf(id)) + expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' }) + }, 60000) +}) + +describe('评分规则写错时指出位置', () => { + test('不认识的操作', () => { + expect(() => parseChecks('- title: 测试\n steps:\n - press: btn\n')).toThrow('第 1 项"测试"第 1 步:不认识的操作 press') + }) + + test('实训台没有这个传感器', () => { + expect(() => parseChecks('- title: 测试\n steps:\n - board: { rain: true }\n')).toThrow('实训台没有 rain') + }) + + test('时长格式不对', () => { + expect(() => parseChecks('- title: 测试\n steps:\n - wait: 6\n')).not.toThrow() + expect(() => parseChecks('- title: 测试\n steps:\n - wait: 6秒\n')).toThrow('时长要写成 6s 或 500ms') + }) +}) diff --git a/shared/grading.ts b/shared/grading.ts new file mode 100644 index 0000000..aafea2c --- /dev/null +++ b/shared/grading.ts @@ -0,0 +1,17 @@ +// 自动评分的结果:服务器给学生程序逐项检查后返回,学生提交后立即能看到。 + +export interface CheckResult { + /** 检查项名称,对应书上评价表的功能项,如"能实时显示入侵数据" */ + title: string + passed: boolean + /** 不通过的原因:期望看到什么、实际是什么 */ + message?: string +} + +export interface GradeResult { + /** 编译没通过时所有项都不通过 */ + compiled: boolean + results: CheckResult[] + /** 评分用时(毫秒) */ + ms: number +} diff --git a/shared/protocol.ts b/shared/protocol.ts index 23850d4..a56a631 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -134,3 +134,5 @@ export type ServerMessage = | { type: 'board'; state: BoardState } | { type: 'status'; state: 'compiling' | 'running' | 'stopped' } | { type: 'serverError'; message: string } + +export * from './grading'