项目 7 情景控制:虚拟时钟、项目资源、Windows Media Player、TrackBar

- 虚拟时钟:实训台时钟面板可拨时间、1×/10×/60× 倍速、恢复真实时间;编译时把
  DateTime.Now / Today / UtcNow 改写成 SimClock;评分规则加 clock 步骤
- 项目资源(Properties.Resources):平台素材库(自绘开关图片、合成门铃声),"项目资源"对话框勾选,
  平台生成 Properties/Resources.Designer.cs;设计器图片框的"图片"属性可选资源;程序目录下的 Resources
  指向素材库;未添加资源时给中文提示
- Windows Media Player(AxWMPLib):设置 URL 自动播放、Ctlcontrols.play/stop,浏览器里播放声音,
  实训台加广播喇叭;编译时给窗体嵌空资源,OcxState 取到 null 不报错;图片和声音统一按内容缓存(blob)
- TrackBar:拖动、点轨道、方向键,先 ValueChanged 再 Scroll;设计器加"滑动条""媒体播放器"
- 评分规则加 value、media 检查;示例项目 7 任务 1–3(修正书上开关灯判断写反、漏填串口列表)
- 修正:Properties/ 下的资源类不当作窗体;带命名空间的 System.AppDomain.CurrentDomain.BaseDirectory 放行
- 测试:虚拟时钟、TrackBar、项目 7 示例评分、作业起点带资源类

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-23 14:11:05 +08:00
1 parent a9af26c2d6
commit eedf1aa2fb
66 files changed
+3538 -43

No files matched your search

+10
View File
@@ -1,5 +1,6 @@
// HTTP 接口。除登录外都要先登录;/api/teacher/* 只有老师能用。
import type { Database } from 'bun:sqlite'
import { dirname } from 'node:path'
import { Hono, type Context } from 'hono'
import { bearerToken, login, logout, refresh, tokenUser, tooManyFailures, type Tokens, type User } from './auth'
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
@@ -71,6 +72,15 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
return c.json({})
})
// 项目资源素材库(Properties.Resources 的图片、声音):设计器预览用 <img> 直接显示,不需要登录
app.get('/resources/:file', async (c) => {
const name = c.req.param('file')
if (!/^[\w.-]+$/.test(name)) return c.notFound()
const file = Bun.file(`${dirname(config.runnerDll)}/Resources/gallery/${name}`)
if (!(await file.exists())) return c.notFound()
return new Response(file, { headers: { 'Cache-Control': 'public, max-age=3600' } })
})
// 其余接口都要先登录(请求头 Authorization: Bearer <令牌>)
app.use('/api/*', async (c, next) => {
const user = await tokenUser(db, bearerToken(c.req.header('authorization')))
+34
View File
@@ -1,4 +1,5 @@
import {
clockWall,
ANALOG_CHANNELS,
ANALOG_INFO,
type BoardState,
@@ -7,6 +8,7 @@ import {
type RunnerInput,
type RunnerMessage,
} from '@iot/shared'
import { config } from './config'
export function defaultBoardState(): BoardState {
return {
@@ -16,6 +18,7 @@ export function defaultBoardState(): BoardState {
zigbee: [false, false],
ledText: '',
camera: { on: false, x: 3, y: 1 },
clock: null,
}
}
@@ -29,6 +32,7 @@ export class Board {
type: 'board',
analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]),
digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]),
clock: this.state.clock,
}
}
@@ -49,9 +53,31 @@ export class Board {
this.state.digital[name] = value
changed = true
}
if (message.clock === null) {
changed ||= this.state.clock !== null
this.state.clock = null
} else if (message.clock && typeof message.clock === 'object') {
changed = this.setClock(message.clock.time, message.clock.speed) || changed
}
return changed
}
/**
* 拨虚拟时钟:time 是墙上时间(yyyy-MM-ddTHH:mm:ss,不给就从现在的时刻接着走),speed 是倍速(不给就保持原来的)。
* 恢复真实时间是 clock = null。
*/
setClock(time: string | undefined, speed: number | undefined) {
if (time !== undefined && (typeof time !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(time) || Number.isNaN(Date.parse(`${time}Z`)))) return false
if (speed !== undefined && (typeof speed !== 'number' || !Number.isFinite(speed) || speed < 1 || speed > 3600)) return false
const now = Date.now()
this.state.clock = {
wall: time ?? (this.state.clock ? clockWall(this.state.clock, now) : wallNow()),
realAt: now,
speed: speed ?? this.state.clock?.speed ?? 1,
}
return true
}
/** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */
applyDevice(message: Extract<RunnerMessage, { type: 'device' }>) {
switch (`${message.device}/${message.action}`) {
@@ -93,3 +119,11 @@ export class Board {
this.state = defaultBoardState()
}
}
/** 服务器所在时区(config.timeZone)现在的墙上时间 yyyy-MM-ddTHH:mm:ss */
export function wallNow(now = new Date()) {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: config.timeZone,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).format(now).replace(' ', 'T')
}
+20 -3
View File
@@ -17,13 +17,15 @@ export type Step =
| { kind: 'edit'; control: string; row: number; column: number | string; value: string }
/** 在日期时间选择器里选日期(和时间):2024-01-15 或 2024-01-15 08:30:00 */
| { kind: 'pick'; control: string; value: string }
/** 拨虚拟时钟到今天的某个时刻(HH:mm:ss),可以同时设倍速 */
| { kind: 'clock'; time: string; speed?: number }
| { kind: 'board'; analog: Partial<Record<AnalogKey, number>>; digital: Partial<Record<DigitalKey, boolean>> }
| { 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(灯泡、报警灯) */
/** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,on → IsOn(灯泡、报警灯),value → Value(滑动条) */
| { kind: 'prop'; control: string; prop: string; value: unknown }
/** 控件文字包含某段文字;列表框是任意一个列表项包含 */
| { kind: 'contains'; control: string; text: string }
@@ -38,6 +40,8 @@ export type Expectation =
| { kind: 'image'; control: string }
/** 摄像头在传画面(on)/ 已关闭(off),或云台比开始时朝某个方向转过 */
| { kind: 'camera'; state: 'on' | 'off' | 'up' | 'down' | 'left' | 'right' }
/** 程序用媒体播放器播放了这个文件(如 ring.mp3) */
| { kind: 'media'; name: string }
/** 程序用 Image.Save 保存了图片,路径包含这段文字(如 Camera/) */
| { kind: 'savedImage'; path: string }
/** 数据表格里有(present)/ 没有一行同时满足这些"列标题=值" */
@@ -47,8 +51,9 @@ export type Expectation =
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
const DEFAULT_WITHIN = 2000
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', on: 'IsOn' }
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', on: 'IsOn', value: 'Value' }
const STRING_PROPS = new Set(['Text', 'LedText'])
const NUMBER_PROPS = new Set(['Value'])
/** "6s"、"500ms" 或毫秒数 */
function duration(value: unknown, where: string): number {
@@ -78,6 +83,7 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
if (!['on', 'off', 'up', 'down', 'left', 'right'].includes(state)) throw new Error(`${where}:camera 要写 on、off、up、down、left 或 right`)
return { kind: 'camera', state: state as Extract<Expectation, { kind: 'camera' }>['state'] }
}
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 ('zigbee' in raw) return { kind: 'zigbee', index: Number(raw.zigbee), on: bool(raw.on, where, 'on') }
@@ -102,7 +108,10 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
}
if ('image' in raw) return { kind: 'image', control }
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) }
if (key in raw) {
if (NUMBER_PROPS.has(prop) && typeof raw[key] !== 'number') throw new Error(`${where}:${key} 要写数字`)
return { kind: 'prop', control, prop, value: STRING_PROPS.has(prop) ? String(raw[key]) : NUMBER_PROPS.has(prop) ? raw[key] : bool(raw[key], where, key) }
}
}
throw new Error(`${where}:不认识的期望 ${JSON.stringify(raw)}`)
}
@@ -140,6 +149,14 @@ function parseStep(raw: unknown, where: string): Step {
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 'clock': {
// clock: 06:44:50 或 clock: { time: 06:44:50, speed: 60 }
const time = typeof value === 'string' ? value : str(obj.time, where, '时刻 time')
if (!/^\d{2}:\d{2}:\d{2}$/.test(time)) throw new Error(`${where}:时刻要写成 06:44:50(加引号)`)
const speed = typeof value === 'object' && obj.speed !== undefined ? Number(obj.speed) : undefined
if (speed !== undefined && !(speed >= 1 && speed <= 3600)) throw new Error(`${where}:倍速 speed 要在 1 到 3600 之间`)
return { kind, time, ...(speed === undefined ? {} : { speed }) }
}
case 'pick': {
const value = str(obj.value, where, '日期 value')
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(value)) throw new Error(`${where}:日期要写成 2024-01-15 或 2024-01-15 08:30:00`)
+12 -1
View File
@@ -2,7 +2,7 @@
// 提示框自动点第一个按钮("确定"/"是"),免得程序一直等在 MessageBox.Show 上。
import type { Subprocess } from 'bun'
import type { ControlNode, RunnerInput, RunnerMessage, UserAction } from '@iot/shared'
import { Board } from '../board'
import { Board, wallNow } from '../board'
import { readLines } from '../runner-process'
import { runCommand } from '../sandbox'
import type { Step } from './checks'
@@ -18,6 +18,8 @@ export class ProgramDriver {
readonly messageBoxes: { text: string; caption: string }[] = []
/** 程序用 Image.Save 保存的图片路径 */
readonly savedImages: string[] = []
/** 媒体播放器播放过的文件名 */
readonly played: string[] = []
readonly exceptions: string[] = []
console = ''
exited = false
@@ -111,6 +113,9 @@ export class ProgramDriver {
case 'imageSaved':
this.savedImages.push(message.path)
break
case 'media':
if (message.action === 'play' && message.name) this.played.push(message.name)
break
case 'console':
this.console += message.text
break
@@ -139,6 +144,12 @@ export class ProgramDriver {
this.send({ type: 'action', id: node.id, ...action })
}
/** 拨虚拟时钟到今天的某个时刻(服务器所在时区) */
setClock(time: string, speed?: number) {
this.board.setClock(`${wallNow().slice(0, 10)}T${time}`, speed)
this.send(this.board.runnerInputs())
}
setBoard(step: BoardStep) {
this.board.applyInput({ type: 'boardInput', analog: step.analog, digital: step.digital })
this.send(this.board.runnerInputs())
+9
View File
@@ -141,6 +141,10 @@ async function runStep(driver: ProgramDriver, step: Step) {
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
@@ -227,6 +231,8 @@ function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'uncha
}
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
@@ -244,6 +250,7 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
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 ? '亮' : '灭'}的`
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': {
@@ -274,6 +281,8 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
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(',')
+17 -1
View File
@@ -35,7 +35,7 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st
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(15))
test('每个示例都有评分规则', () => expect(ids.length).toBe(18))
for (const id of ids) {
test(id, async () => {
const result = await grade(await prepare(id, id), await checksOf(id), id)
@@ -110,6 +110,22 @@ describe('改坏的代码', () => {
expect(result.results[0].message).toStartWith('程序出错:System.ArgumentNullException')
}, 60000)
test('情景控制:照书上把手动开关灯的判断写反,点了灯不亮', async () => {
const id = 'p7-t1-智慧教室'
const dir = await prepare(id, 'lamp-reversed', { file: '教室情景模式.cs', from: ' if (IsLampOpen)\n', to: ' if (!IsLampOpen)\n' })
const checks = [
...(await checksOf(id)),
...parseChecks(`
- title: 能手动开关灯
steps:
- click: pic_lightsStatus
- expect: { control: lamp, on: true, desc: 点开关后灯亮 }
`),
]
const result = await grade(dir, checks, id)
expect(result.results.at(-1)).toMatchObject({ passed: false, message: '期望窗体上的 lamp 点开关后灯亮,实际是灭的' })
}, 60000)
test('关闭时忘了挂起线程', async () => {
const id = 'p5-t2-烟雾'
const dir = await prepare(id, 'thread', { file: 'Form1.cs', from: 'thd.Suspend();//挂起线程', to: '' })
+52 -1
View File
@@ -4,6 +4,7 @@ import { afterAll, afterEach, describe, expect, test } from 'bun:test'
import { cp, mkdir, rm } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared'
import { wallNow } from '../src/board'
import { compileService } from '../src/compile-service'
import { config, ROOT } from '../src/config'
import { cpuLimitMessage, runCommand } from '../src/sandbox'
@@ -332,7 +333,7 @@ test('项目4 任务2:打开摄像机显示画面,转动后画面变化,
const center = imageOf(await first)
expect(center).toMatchObject({ width: 640, height: 480 })
// 图片数据先于(或随)属性变化发给浏览器,同一张图只发一次
const sent = runner.messages.filter((m) => m.type === 'image' && m.key === center.key)
const sent = runner.messages.filter((m) => m.type === 'blob' && m.key === center.key)
expect(sent.length).toBe(1)
expect(sent[0]).toMatchObject({ mime: 'image/jpeg' })
@@ -630,6 +631,56 @@ test('数据库:照书上的写法建库建表、插入、查询、SqlCommandB
}
}, 30000)
test('虚拟时钟:拨时间、倍速后 DateTime.Now / Today 跟着走,恢复后是真实时间', async () => {
const { runner } = await runMain(
'clock',
`
for (int i = 0; i < 40; i++)
{
Console.WriteLine("now=" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " today=" + DateTime.Today.ToString("yyyy-MM-dd"));
System.Threading.Thread.Sleep(100);
}`,
)
expect((await runner.waitFor('compiled')).ok).toBe(true)
await runner.waitFor('console')
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: DIGITAL_OFF, clock: { wall: '2024-01-16T06:44:58', realAt: Date.now(), speed: 60 } })
// 60 倍速:真实的 1 秒是虚拟的 1 分钟
await runner.next('console', (m) => /now=2024-01-16 06:4[6-9]/.test(m.text), 5000)
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: DIGITAL_OFF, clock: null })
const real = await runner.next('console', (m) => !m.text.includes('2024-01-16'), 3000)
// 学生程序按服务器配置的时区(config.timeZone)计时,CI 机器本身是 UTC
expect(real.text).toContain(`today=${wallNow().slice(0, 10)}`)
expect(runner.messages.some((m) => m.type === 'console' && m.text.includes('today=2024-01-16'))).toBe(true)
}, 30000)
test('TrackBar:拖动时先 ValueChanged 再 Scroll,超出范围的值截到两端;代码设超出范围的 Value 报错', async () => {
const { runner } = await runMain(
'trackbar',
`
var form = new Form();
var bar = new TrackBar { Name = "bar", Minimum = 18, Maximum = 32 };
bar.ValueChanged += (s, e) => Console.WriteLine("changed=" + bar.Value);
bar.Scroll += (s, e) => Console.WriteLine("scroll=" + bar.Value);
form.Load += (s, e) =>
{
try { bar.Value = 40; } catch (ArgumentOutOfRangeException ex) { Console.WriteLine("error=" + ex.Message); }
};
form.Controls.Add(bar);
Application.Run(form);`,
'using System.Windows.Forms;',
)
const { form } = await runner.waitFor('formShown')
const bar = findByName(form, 'bar')
expect(bar.props).toMatchObject({ Minimum: 18, Maximum: 32, Value: 18 })
runner.send({ type: 'action', id: bar.id, action: 'scroll', value: '26' })
await runner.waitFor('console', (m) => m.text === 'scroll=26\n')
runner.send({ type: 'action', id: bar.id, action: 'scroll', value: '99' })
await runner.waitFor('console', (m) => m.text === 'scroll=32\n')
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toContain("error=“40”的值对于“Value”无效。")
expect(output).toContain('changed=26\nscroll=26\nchanged=32\nscroll=32\n')
}, 30000)
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
process.env.SIM_TEST_SECRET = '不该被看到'
const { runner } = await runMain(