第 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:
1 parent
98be8331bc
commit
26104ae81e
38 files changed
+2346
-77
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user