From b20db5721ed940affac3ecbd889933d3717670cb Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 23 Oct 2026 15:14:49 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=84=E5=88=86=E8=A7=84=E5=88=99=E5=8A=A0?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E3=80=81=E7=AA=97=E4=BD=93=E3=80=81=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E9=80=80=E5=87=BA=E3=80=81=E7=B4=A0=E6=9D=90=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E3=80=81=E5=9B=BE=E8=A1=A8=E6=95=B0=E6=8D=AE=E7=82=B9?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - visible:控件连同所在的容器都可见;window:某个窗体打开着 / 已关闭;exited:程序已退出 - image / background 可以写素材名(如 background: day_01),按图片内容的哈希核对是不是这张素材 - points:图表每个系列至少有几个数据点 - "点击"图片框时和浏览器一样先发鼠标按下、松开(教材项目 8 的方向键按下时转动摄像机) Co-Authored-By: Claude Opus 5.5 --- server/src/grading/checks.ts | 31 +++++++++++--- server/src/grading/driver.ts | 8 ++++ server/src/grading/grader.ts | 78 ++++++++++++++++++++++++++++++++---- 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/server/src/grading/checks.ts b/server/src/grading/checks.ts index 3c3c64d..793af7c 100644 --- a/server/src/grading/checks.ts +++ b/server/src/grading/checks.ts @@ -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 = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', on: 'IsOn', value: 'Value' } +const PROP_KEYS: Record = { 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, 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, 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} 要写数字`) diff --git a/server/src/grading/driver.ts b/server/src/grading/driver.ts index fbcaa18..3c4ed1e 100644 --- a/server/src/grading/driver.ts +++ b/server/src/grading/driver.ts @@ -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 diff --git a/server/src/grading/grader.ts b/server/src/grading/grader.ts index 09100a3..16f52c4 100644 --- a/server/src/grading/grader.ts +++ b/server/src/grading/grader.ts @@ -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() + +async function galleryKey(name: string): Promise { + 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 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): boolean { +/** 控件属性里的图片({ key, width, height }) */ +const imageKey = (value: unknown) => (value as { key?: string } | null | undefined)?.key + +function satisfied(driver: ProgramDriver, e: Exclude, 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 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, 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 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': {