第 1 阶段:虚拟实训台,项目 2、3 的示例跑通

- 实训台状态由服务器统一保存:滑块和开关设定传感器读数,推给学生程序;
  学生程序控制的灯、继电器、LED 屏回写到实训台
- 网页按教材实训台布局图绘制实训台,传感器卡片上直接调节环境
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),Lamp、Alarm
- 设备类库:串口未打开时控制无效,与实物一致
- 修正:列表属性序列化时数字被转成字符串
- 示例:项目 2 任务 2–3、项目 3 任务 1–2;网址参数 ?sample= 打开指定示例
- 测试:实训台单元测试,项目 2、3 的端到端测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 10:32:38 +08:00
1 parent 98be8331bc
commit 26104ae81e
38 files changed
+2346 -77

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
import {
ANALOG_CHANNELS,
ANALOG_INFO,
type BoardState,
type ClientMessage,
DIGITAL_CHANNELS,
type RunnerInput,
type RunnerMessage,
} from '@iot/shared'
export function defaultBoardState(): BoardState {
return {
analog: { temperature: 25, humidity: 60, light: 500, airPressure: 101, airQuality: 60, co2: 600, wind: 3 },
digital: { bodyInfrared: false, fire: false, smoke: false, infrared: false },
relays: [false, false, false, false, false],
zigbee: [false, false],
ledText: '',
}
}
/** 一个浏览器会话的虚拟实训台。多次运行之间保留状态,和真实实训台断电前继电器保持原状一样 */
export class Board {
state = defaultBoardState()
/** 发给运行器的传感器读数,按通道号排列 */
runnerInputs(): Extract<RunnerInput, { type: 'board' }> {
return {
type: 'board',
analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]),
digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]),
}
}
/** 浏览器调节环境;返回 true 表示传感器读数有变化 */
applyInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
let changed = false
for (const [key, value] of Object.entries(message.analog ?? {})) {
const name = key as keyof BoardState['analog']
if (!(name in ANALOG_INFO) || typeof value !== 'number' || !Number.isFinite(value)) continue
const { min, max } = ANALOG_INFO[name]
// 教材类库返回整数字符串,这里保证读数是整数
this.state.analog[name] = Math.round(Math.min(max, Math.max(min, value)))
changed = true
}
for (const [key, value] of Object.entries(message.digital ?? {})) {
const name = key as keyof BoardState['digital']
if (!(name in this.state.digital) || typeof value !== 'boolean') continue
this.state.digital[name] = value
changed = true
}
return changed
}
/** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */
applyDevice(message: Extract<RunnerMessage, { type: 'device' }>) {
switch (`${message.device}/${message.action}`) {
case 'led/text':
this.state.ledText = String(message.data ?? '')
return true
case 'adam4150/do': {
const [channel, on] = message.data as [number, number]
if (channel < 0 || channel >= this.state.relays.length) return false
this.state.relays[channel] = on === 1
return true
}
case 'zigbee/relay': {
const [relay, on] = message.data as [number, number]
if (relay < 0 || relay >= this.state.zigbee.length) return false
this.state.zigbee[relay] = on === 1
return true
}
default:
return false
}
}
reset() {
this.state = defaultBoardState()
}
}
+4
View File
@@ -50,6 +50,10 @@ const server = Bun.serve({
}
} else if (message.type === 'stop') {
await session.stop()
} else if (message.type === 'boardInput') {
session.boardInput(message)
} else if (message.type === 'boardReset') {
session.boardReset()
} else {
session.input(message)
}
+38 -4
View File
@@ -1,7 +1,8 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join, normalize, resolve } from 'node:path'
import type { Subprocess } from 'bun'
import type { RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
import { Board } from './board'
import { compileService } from './compile-service'
import { ROOT, RUNNER_DLL, readLines } from './runner-process'
@@ -12,8 +13,12 @@ export async function cleanupStaleRuns() {
await rm(RUNS_DIR, { recursive: true, force: true })
}
/** 一次运行:写出学生工程 → 编译服务编译 → 启动运行进程,在运行进程和浏览器之间转发消息 */
/**
* 一个浏览器会话:拥有一台虚拟实训台,负责每次运行(写出学生工程 → 编译服务编译 → 启动运行进程),
* 在运行进程和浏览器之间转发消息,并让实训台状态在两边保持一致。
*/
export class RunSession {
readonly board = new Board()
private proc?: Subprocess<'pipe', 'pipe', 'pipe'>
private dir?: string
/** 每次启动、停止都递增,用来丢弃已经过时的编译结果 */
@@ -21,7 +26,9 @@ export class RunSession {
/** 同一会话的多次"运行"排队依次执行,避免两次启动交错 */
private queue = Promise.resolve()
constructor(private readonly send: (message: ServerMessage) => void) {}
constructor(private readonly send: (message: ServerMessage) => void) {
this.sendBoard()
}
start(files: SourceFile[]) {
const next = this.queue.then(() => this.startNow(files))
@@ -59,7 +66,13 @@ export class RunSession {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
env: {
...process.env,
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
DOTNET_NOLOGO: '1',
// 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息
SIM_BOARD: JSON.stringify(this.board.runnerInputs()),
},
})
this.proc = proc
@@ -72,6 +85,10 @@ export class RunSession {
return
}
if (message.type === 'started') this.send({ type: 'status', state: 'running' })
if (message.type === 'device') {
if (this.board.applyDevice(message)) this.sendBoard()
return
}
this.send(message)
})
void readLines(proc.stderr, (line) => this.send({ type: 'serverError', message: line }))
@@ -85,6 +102,23 @@ export class RunSession {
})
}
/** 浏览器调节实训台环境:更新状态,推给浏览器和正在运行的学生程序 */
boardInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
if (!this.board.applyInput(message)) return
this.sendBoard()
this.input(this.board.runnerInputs())
}
boardReset() {
this.board.reset()
this.sendBoard()
this.input(this.board.runnerInputs())
}
private sendBoard() {
this.send({ type: 'board', state: structuredClone(this.board.state) })
}
input(message: RunnerInput) {
if (!this.proc) return
this.proc.stdin.write(`${JSON.stringify(message)}\n`)
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from 'bun:test'
import { Board } from '../src/board'
test('滑块输入取整并限制在范围内,开关输入只接受布尔值', () => {
const board = new Board()
expect(board.applyInput({ type: 'boardInput', analog: { temperature: 31.6, light: 99999 } })).toBe(true)
expect(board.state.analog.temperature).toBe(32)
expect(board.state.analog.light).toBe(2000)
board.applyInput({ type: 'boardInput', digital: { fire: true, smoke: 'yes' as unknown as boolean } })
expect(board.state.digital.fire).toBe(true)
expect(board.state.digital.smoke).toBe(false)
// 发给运行器的读数按通道号排列:Vin0 温度、Vin1 湿度、Vin2 光照……;DI0 人体红外、DI1 火焰……
const inputs = board.runnerInputs()
expect(inputs.analog.slice(0, 3)).toEqual([32, 60, 2000])
expect(inputs.digital).toEqual([false, true, false, false])
})
test('学生程序控制输出设备:LED 屏、4150 继电器、ZigBee 继电器', () => {
const board = new Board()
expect(board.applyDevice({ type: 'device', device: 'led', action: 'text', data: '欢迎' })).toBe(true)
expect(board.applyDevice({ type: 'device', device: 'adam4150', action: 'do', data: [0, 1] })).toBe(true)
expect(board.applyDevice({ type: 'device', device: 'zigbee', action: 'relay', data: [1, 1] })).toBe(true)
expect(board.applyDevice({ type: 'device', device: 'adam4150', action: 'do', data: [9, 1] })).toBe(false)
expect(board.state.ledText).toBe('欢迎')
expect(board.state.relays[0]).toBe(true)
expect(board.state.zigbee).toEqual([false, true])
board.reset()
expect(board.state.relays.every((on) => !on)).toBe(true)
expect(board.state.ledText).toBe('')
})
+96 -6
View File
@@ -1,6 +1,7 @@
// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材项目 2 任务 1 的行为。
// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材示例工程的行为。
// 运行前先编译运行时:bun run build:runtime
import { afterAll, afterEach, expect, test } from 'bun:test'
import { cp, rm } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared'
import { compileService } from '../src/compile-service'
@@ -47,6 +48,16 @@ class Runner {
}
}
/** 只等待从现在起新收到的消息(不算已经收到的) */
next<T extends RunnerMessage['type']>(
type: T,
match: (m: Extract<RunnerMessage, { type: T }>) => boolean = () => true,
timeoutMs = 15000,
): Promise<Extract<RunnerMessage, { type: T }>> {
const from = this.messages.length
return this.waitFor(type, (m) => this.messages.indexOf(m) >= from && match(m), timeoutMs)
}
/** 等待下一条满足条件的消息(已经收到的也算) */
waitFor<T extends RunnerMessage['type']>(
type: T,
@@ -87,16 +98,32 @@ function findByName(node: ControlNode, name: string): ControlNode {
return { id: '', type: '', name: '', props: {}, children: [] }
}
/** 编译产物写在工程目录里,所以把示例复制到临时目录再编译 */
async function prepareSample(id: string) {
const dir = resolve(ROOT, '.runs', `test-${id}`)
await rm(dir, { recursive: true, force: true })
await cp(resolve(ROOT, 'samples', id), dir, { recursive: true })
return dir
}
/** 启动示例并等窗体显示;返回按控件名查 id 的函数 */
async function startSample(id: string) {
runner = await Runner.start(await prepareSample(id))
const compiled = await runner.waitFor('compiled')
expect(compiled.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
const { form } = await runner.waitFor('formShown')
return { runner, form, id: (name: string) => findByName(form, name).id }
}
const ANALOG_DEFAULT = [25, 60, 500, 101, 60, 600, 3]
const DIGITAL_OFF = [false, false, false, false]
let runner: Runner | undefined
afterEach(() => runner?.kill())
afterAll(() => compileService.stop())
test('项目2 任务1:选串口、打开、推送文字到 LED 屏', async () => {
// 编译产物写在工程目录里,所以复制到临时目录再编译
const dir = resolve(ROOT, '.runs/test-led')
const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏')
for (const name of ['Program.cs', 'Form1.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`))
runner = await Runner.start(dir)
runner = await Runner.start(await prepareSample('p2-t1-LED显示屏'))
const compiled = await runner.waitFor('compiled')
expect(compiled.diagnostics).toEqual([])
const { form } = await runner.waitFor('formShown')
@@ -147,3 +174,66 @@ test('编译错误:书上常见的 True 大写给出中文提示', async () =>
expect(error?.message).toContain('True')
expect(error?.hint).toContain('小写')
}, 30000)
test('项目2 任务2:温度读数来自实训台,滑块变化后再次获取得到新值', async () => {
const { runner, id } = await startSample('p2-t2-模拟量采集')
const port = id('com_port')
// 串口列表在 Load 里就已填好
runner.send({ type: 'action', id: port, action: 'select', index: 1 })
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
const textOf = (name: string) =>
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && c.prop === 'Text')).then(
(m) => m.changes.findLast((c) => c.id === id(name) && c.prop === 'Text')!.value,
)
let temperature = textOf('txt_temperature')
runner.send({ type: 'action', id: id('btn_get'), action: 'click' })
expect(await temperature).toBe('25')
runner.send({ type: 'board', analog: [31, 60, 300, 101, 60, 600, 3], digital: DIGITAL_OFF })
temperature = textOf('txt_temperature')
const light = textOf('txt_light')
runner.send({ type: 'action', id: id('btn_get'), action: 'click' })
expect(await temperature).toBe('31')
expect(await light).toBe('300')
}, 30000)
test('项目3 任务1:单选按钮切换,事件顺序与 WinForms 一致,最终灯的状态正确', async () => {
const { runner, id } = await startSample('p3-t1-ZigBee路灯')
const lamp = id('lamp1')
// 串口没打开时控制无效:没有继电器动作,但界面上的灯照样会亮(学生能看出两者不一致)
runner.send({ type: 'action', id: id('rb_Open'), action: 'click' })
await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === lamp && c.prop === 'IsOn' && c.value === true))
expect(runner.messages.some((m) => m.type === 'device')).toBe(false)
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 3 })
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
// 选"关":先触发"开"的 CheckedChanged(又开一次),再触发"关"的 CheckedChanged,最终关灯
const relayOff = runner.next('device', (m) => m.device === 'zigbee' && (m.data as number[])[1] === 0)
runner.send({ type: 'action', id: id('rb_Close'), action: 'click' })
const devices: unknown[] = []
await relayOff
for (const m of runner.messages) if (m.type === 'device') devices.push(m.data)
expect(devices).toEqual([
[1, 1],
[1, 0],
])
}, 30000)
test('项目3 任务2:勾选人体开关后,有人经过时 5 秒内路灯打开', async () => {
const { runner, id } = await startSample('p3-t2-人体红外ZigBee联动')
runner.send({ type: 'action', id: id('com_lamp'), action: 'select', index: 3 })
runner.send({ type: 'action', id: id('com_environment'), action: 'select', index: 2 })
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
// 复选框的 Click 里读取 Checked:此时 Checked 必须已经是 true
const disabled = runner.next('patch', (m) => m.changes.some((c) => c.id === id('btn_lampOpen') && c.prop === 'Enabled' && c.value === false))
runner.send({ type: 'action', id: id('ch_bodyInfrared'), action: 'click' })
await disabled
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [true, false, false, false] })
const on = await runner.next('device', (m) => m.device === 'zigbee', 7000)
expect(on.data).toEqual([1, 1])
}, 30000)