评分规则加可见、窗体、程序退出、素材图片、图表数据点检查

- visible:控件连同所在的容器都可见;window:某个窗体打开着 / 已关闭;exited:程序已退出
- image / background 可以写素材名(如 background: day_01),按图片内容的哈希核对是不是这张素材
- points:图表每个系列至少有几个数据点
- "点击"图片框时和浏览器一样先发鼠标按下、松开(教材项目 8 的方向键按下时转动摄像机)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-23 15:14:49 +08:00
1 parent 10c50265b2
commit b20db5721e
3 files changed
+104 -13

No files matched your search

+26 -5
View File
@@ -25,7 +25,7 @@ export type Step =
| { kind: 'expect'; expect: Expectation; within: number; desc?: string }
export type Expectation =
/** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,on → IsOn(灯泡、报警灯),value → Value(滑动条) */
/** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,visible → Visible(连同所在的容器都可见),on → IsOn(灯泡、报警灯),value → Value(滑动条) */
| { kind: 'prop'; control: string; prop: string; value: unknown }
/** 控件文字包含某段文字;列表框是任意一个列表项包含 */
| { kind: 'contains'; control: string; text: string }
@@ -36,8 +36,14 @@ export type Expectation =
| { kind: 'relay'; index: number; on: boolean }
| { kind: 'zigbee'; index: number; on: boolean }
| { kind: 'messageBox'; text: string }
/** 图片框里显示了图片 */
| { kind: 'image'; control: string }
/** 控件显示了图片:prop 是 Image(图片框的图片)或 BackgroundImage(背景图片);name 是素材库里的资源名(如 day_01),不写时只要有图片 */
| { kind: 'image'; control: string; prop: 'Image' | 'BackgroundImage'; name?: string }
/** 窗体(按窗体的 Name)打开着 / 已关闭 */
| { kind: 'window'; name: string; open: boolean }
/** 程序已经退出(主窗体关闭) */
| { kind: 'exited' }
/** 图表的每个系列都至少有这么多个数据点 */
| { kind: 'chart'; control: string; points: number }
/** 摄像头在传画面(on)/ 已关闭(off),或云台比开始时朝某个方向转过 */
| { kind: 'camera'; state: 'on' | 'off' | 'up' | 'down' | 'left' | 'right' }
/** 程序用媒体播放器播放了这个文件(如 ring.mp3) */
@@ -51,7 +57,7 @@ export type Expectation =
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
const DEFAULT_WITHIN = 2000
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', on: 'IsOn', value: 'Value' }
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', visible: 'Visible', on: 'IsOn', value: 'Value' }
const STRING_PROPS = new Set(['Text', 'LedText'])
const NUMBER_PROPS = new Set(['Value'])
@@ -86,6 +92,11 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
if ('media' in raw) return { kind: 'media', name: str(raw.media, where, '播放的文件名') }
if ('savedImage' in raw) return { kind: 'savedImage', path: str(raw.savedImage, where, '保存的图片路径应包含的文字') }
if ('relay' in raw) return { kind: 'relay', index: Number(raw.relay), on: bool(raw.on, where, 'on') }
if ('window' in raw) return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
if ('exited' in raw) {
if (raw.exited !== true) throw new Error(`${where}:exited 只能写 true`)
return { kind: 'exited' }
}
if ('zigbee' in raw) return { kind: 'zigbee', index: Number(raw.zigbee), on: bool(raw.on, where, 'on') }
const control = str(raw.control, where, '控件名 control')
@@ -106,7 +117,17 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
}
return { kind: 'row', control, cells, present: 'row' in raw }
}
if ('image' in raw) return { kind: 'image', control }
// image: true 或 image: 资源名;background: 资源名
for (const [key, prop] of [['image', 'Image'], ['background', 'BackgroundImage']] as const) {
if (!(key in raw)) continue
const value = raw[key]
if (value === true) return { kind: 'image', control, prop }
return { kind: 'image', control, prop, name: str(value, where, `${key} 要写 true 或素材库里的资源名`) }
}
if ('points' in raw) {
if (typeof raw.points !== 'number' || raw.points < 1) throw new Error(`${where}:points 要写正整数`)
return { kind: 'chart', control, points: raw.points }
}
for (const [key, prop] of Object.entries(PROP_KEYS)) {
if (key in raw) {
if (NUMBER_PROPS.has(prop) && typeof raw[key] !== 'number') throw new Error(`${where}:${key} 要写数字`)
+8
View File
@@ -138,6 +138,14 @@ export class ProgramDriver {
}
}
/** 控件连同它所在的容器都可见(Visible 不是 false) */
isVisible(node: ControlNode): boolean {
for (let n: ControlNode | undefined = node; n; n = this.parents.get(n.id)) {
if (n.props.Visible === false) return false
}
return true
}
act(node: ControlNode, action: UserAction) {
// 和浏览器一样同步记下输入的文字(运行器不回传)
if (action.action === 'text') node.props.Text = action.value
+70 -8
View File
@@ -2,6 +2,8 @@
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。
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'
@@ -77,6 +79,21 @@ async function runCheck(dir: string, check: Check, sampleId?: string): Promise<C
}
}
/**
* 素材库图片的编号:运行器按图片内容的 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} 的控件(书上规定的控件名,注意大小写)`)
@@ -88,6 +105,11 @@ async function runStep(driver: ProgramDriver, step: Step) {
case 'click': {
const node = need(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
@@ -193,7 +215,13 @@ async function expectStep(driver: ProgramDriver, step: Extract<Step, { kind: 'ex
return
}
const ok = await driver.waitFor(() => satisfied(driver, e), within)
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))
}
@@ -202,11 +230,17 @@ function listItems(node: ControlNode | undefined): string[] | undefined {
return node?.type === 'ListBox' ? ((node.props.Items as unknown[] | undefined) ?? []).map(String) : undefined
}
function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>): boolean {
/** 控件属性里的图片({ 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':
return props(e.control)?.[e.prop] === e.value
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)
@@ -223,8 +257,18 @@ function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'uncha
return driver.messageBoxes.some((box) => box.text.includes(e.text))
case 'console':
return driver.console.includes(e.text)
case 'image':
return Boolean(props(e.control)?.Image)
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]
@@ -240,6 +284,10 @@ function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'uncha
}
}
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 ? '打开' : '关闭')
@@ -248,6 +296,7 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
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)}`
@@ -273,8 +322,21 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
}
case 'console':
return `期望${prefix}控制台${desc ?? `输出"${e.text}"`},实际输出${quote(driver.console.trim())}`
case 'image':
return `期望${prefix} ${e.control} ${desc ?? '显示图片'},实际是空的`
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': {