Files
IOT_SIM/server/test/runner.test.ts
T
xuyueandClaude Opus 5.5 f6024dc113
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
试用前走查的修改:报错看得见、学生找得到作业、运行区和设计器的若干体验问题
- 运行区:编译错误、运行异常、控制台输出和保存的图片移到窗体下面,不再被实训台挤成 0 高;缩放和放大按钮移到桌面上方,不压窗体的关闭按钮;放大后也能停止程序;点停止后清掉窗体
- 运行异常:常见 .NET 异常配中文说明,显示出错的文件、行号和方法,点击跳到代码
- 运行中的下拉列表挂到页面最外层,能伸出窗体和分组框(小窗体也能选到 COM4、COM5)
- 学生登录后进"我的作业";任务面板从工具栏下面开始,不挡导航;记住上次做的实验,从数据库页面返回、恢复数据时默认用它
- 代码页和设计页左栏都占 3/5,文件标签放不下时换行;实验选择框加宽;关闭粘性滚动
- 设计器:双击生成的事件方法带缩进;双击工具箱放进选中控件所在的容器并错开位置;新控件比容器宽时按比例缩小;无边框窗体不画标题栏
- 中文界面:代码编辑器(查找替换等)、属性窗口的颜色选项
- SQL:DATEADD、DATEDIFF、DATEPART 的日期单位(day、hour)解析成 IdentifierLiteral 时报"不支持",补上
- 项目 8 的预置环境数据改成建库那天之前的 7 天;p6-t3 评分项改为"按日期查询环境数据"
- 没有 AI 建议时不显示"全部采纳 AI 建议"和相关说明;导出 CSV 只给纯数字的文字加制表符

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:49:42 +08:00

850 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材示例工程的行为。
// 运行前先编译运行时:bun run build:runtime
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'
class Runner {
readonly messages: RunnerMessage[] = []
private proc?: Bun.Subprocess<'pipe', 'pipe', 'inherit'>
private reading?: Promise<void>
private waiters: { match: (m: RunnerMessage) => boolean; resolve: (m: RunnerMessage) => void }[] = []
/** 与服务器相同的流程:先由编译服务编译,成功后启动运行进程 */
static async start(projectDir: string, dbDir?: string) {
const runner = new Runner()
const compiled = await compileService.compile(projectDir)
runner.receive({ type: 'compiled', ...compiled })
if (compiled.ok) {
const { cmd, env } = runCommand(projectDir, {}, dbDir)
runner.proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit', env })
runner.reading = runner.read()
}
return runner
}
private receive(message: RunnerMessage) {
this.messages.push(message)
this.waiters = this.waiters.filter((w) => {
if (!w.match(message)) return true
w.resolve(message)
return false
})
}
private async read() {
const decoder = new TextDecoder()
let buffer = ''
for await (const chunk of this.proc!.stdout) {
buffer += decoder.decode(chunk, { stream: true })
let index: number
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index)
buffer = buffer.slice(index + 1)
if (!line.trim()) continue
this.receive(JSON.parse(line) as RunnerMessage)
}
}
}
/** 只等待从现在起新收到的消息(不算已经收到的) */
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,
match: (m: Extract<RunnerMessage, { type: T }>) => boolean = () => true,
timeoutMs = 15000,
): Promise<Extract<RunnerMessage, { type: T }>> {
const isMatch = (m: RunnerMessage) => m.type === type && match(m as Extract<RunnerMessage, { type: T }>)
const existing = this.messages.find(isMatch)
if (existing) return Promise.resolve(existing as Extract<RunnerMessage, { type: T }>)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`等待 ${type} 超时`)), timeoutMs)
this.waiters.push({
match: isMatch,
resolve: (m) => {
clearTimeout(timer)
resolve(m as Extract<RunnerMessage, { type: T }>)
},
})
})
}
send(message: RunnerInput) {
this.proc!.stdin.write(`${JSON.stringify(message)}\n`)
this.proc!.stdin.flush()
}
kill() {
this.proc?.kill()
}
/** 等程序自己结束、输出全部读完 */
async finished() {
await this.proc!.exited
await this.reading
return { code: this.proc!.exitCode, signal: this.proc!.signalCode }
}
}
function findByName(node: ControlNode, name: string): ControlNode {
if (node.name === name) return node
for (const child of node.children) {
const found = findByName(child, name)
if (found.name === name) return found
}
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 () => {
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')
expect(form.props.Text).toBe('LED显示屏')
const id = (name: string) => findByName(form, name).id
const comPort = id('com_Port')
// 先推送、不打开串口:教材约定返回 "Error:发送失败",学生代码弹出 MessageBox
runner.send({ type: 'action', id: id('txt_SendText'), action: 'text', value: '欢迎学习物联网' })
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
const box = await runner.waitFor('messageBox')
expect(box.text).toBe('Error:发送失败')
runner.send({ type: 'reply', requestId: box.requestId, result: 'OK' })
// 点下拉框:Click 事件里填充串口列表
runner.send({ type: 'action', id: comPort, action: 'dropDown' })
const items = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === comPort && c.prop === 'Items'))
const ports = items.changes.findLast((c) => c.id === comPort && c.prop === 'Items')!.value as string[]
expect(ports).toContain('COM5')
runner.send({ type: 'action', id: comPort, action: 'select', index: ports.indexOf('COM5') })
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
const device = await runner.waitFor('device', (m) => m.device === 'led')
expect(device.data).toBe('欢迎学习物联网')
const led = id('led')
const ledPatch = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === led && c.prop === 'LedText'))
expect(ledPatch.changes.find((c) => c.id === led)!.value).toBe('欢迎学习物联网')
// 关闭主窗体:程序结束
runner.send({ type: 'action', id: form.id, action: 'close' })
await runner.waitFor('formClosed')
}, 30000)
test('编译错误:书上常见的 True 大写给出中文提示', async () => {
const dir = resolve(ROOT, '.runs/test-true')
const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏')
for (const name of ['Program.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`))
const form1 = await Bun.file(`${sample}/Form1.cs`).text()
await Bun.write(
`${dir}/Form1.cs`,
form1.replace(
'private void btn_Open_Click(object sender, EventArgs e)\n {',
'private void btn_Open_Click(object sender, EventArgs e)\n {\n bool IsOpen = True;',
),
)
runner = await Runner.start(dir)
const compiled = await runner.waitFor('compiled')
expect(compiled.ok).toBe(false)
const error = compiled.diagnostics.find((d) => d.code === 'CS0103')
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)
test('项目1 任务1:多窗体——打开增加用户窗体、添加用户、关闭后登录成功,可以再次打开', async () => {
const { runner, form: login, id } = await startSample('p1-t1-登录')
expect(login.props.Text).toBe('登录')
runner.send({ type: 'action', id: id('btnAdd'), action: 'click' })
const { form: addUser } = await runner.waitFor('formShown', (m) => m.form.props.Text === '增加用户')
const addId = (name: string) => findByName(addUser, name).id
runner.send({ type: 'action', id: addId('txtNewUser1'), action: 'text', value: 'admin' })
runner.send({ type: 'action', id: addId('txtPass1'), action: 'text', value: '123' })
runner.send({ type: 'action', id: addId('txtPass2'), action: 'text', value: '123' })
runner.send({ type: 'action', id: addId('btnOk'), action: 'click' })
await runner.waitFor('formClosed', (m) => m.formId === addUser.id)
runner.send({ type: 'action', id: id('txtUserName'), action: 'text', value: 'admin' })
runner.send({ type: 'action', id: id('txtPassword'), action: 'text', value: '123' })
runner.send({ type: 'action', id: id('btnOk'), action: 'click' })
const box = await runner.waitFor('messageBox')
expect(box.text).toBe('登录成功,欢迎进入!')
runner.send({ type: 'reply', requestId: box.requestId, result: 'OK' })
// 再次打开增加用户窗体:每次新建实例,不会因为上一个已释放而出错
runner.send({ type: 'action', id: id('btnAdd'), action: 'click' })
const second = await runner.next('formShown', (m) => m.form.props.Text === '增加用户')
expect(second.form.id).not.toBe(addUser.id)
expect(runner.messages.some((m) => m.type === 'exception')).toBe(false)
}, 30000)
describe('所有示例都能编译通过', async () => {
const ids = (await Array.fromAsync(new Bun.Glob('*/meta.json').scan({ cwd: resolve(ROOT, 'samples') }))).map((p) => p.split('/')[0]).sort()
for (const id of ids) {
test(
id,
async () => {
const result = await compileService.compile(await prepareSample(id))
expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
},
30000,
)
}
})
test('项目1 任务2:计算器(装箱拆箱)和多态', async () => {
const { runner, id } = await startSample('p1-t2-面向对象特性')
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)
runner.send({ type: 'action', id: id('txtNum1'), action: 'text', value: '7' })
runner.send({ type: 'action', id: id('txtNum2'), action: 'text', value: '2' })
runner.send({ type: 'action', id: id('cmboxType'), action: 'select', index: 3 })
const result = textOf('txtResult')
runner.send({ type: 'action', id: id('btnCalculate'), action: 'click' })
expect(await result).toBe('3.5')
const display = textOf('txtDispaly')
runner.send({ type: 'action', id: id('btnDisplay'), action: 'click' })
expect(await display).toBe('张三说的是汉语!\r\n李四说的是汉语!\r\nMary说的是英语!\r\nJimmy说的是英语!\r\n')
}, 30000)
/** 选 COM3、点"开启",等定时器读到实训台上的传感器 */
async function openAdam4150(runner: Runner, id: (name: string) => string) {
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 2 })
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
}
test('项目4 任务1:红外对射被遮挡时显示"发现入侵"', async () => {
const { runner, id } = await startSample('p4-t1-红外对射')
await openAdam4150(runner, id)
const even = (value: string) => runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === value), 5000)
await even('正常')
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] })
await even('发现入侵')
}, 30000)
test('项目4 任务2:打开摄像机显示画面,转动后画面变化,有人翻墙时画面变化,拍照保存到 Camera 文件夹', async () => {
const { runner, id } = await startSample('p4-t2-网络摄像机')
const pictures = () => runner.next('patch', (m) => m.changes.some((c) => c.id === id('pictureBox1') && c.prop === 'Image'), 5000)
const imageOf = (patch: Extract<RunnerMessage, { type: 'patch' }>) =>
patch.changes.find((c) => c.id === id('pictureBox1') && c.prop === 'Image')!.value as { key: string; width: number; height: number }
runner.send({ type: 'action', id: id('txt_ip'), action: 'text', value: '192.168.2.50' })
const first = pictures()
const cameraOn = runner.next('device', (m) => m.device === 'camera' && m.action === 'on' && m.data === true)
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await cameraOn
const center = imageOf(await first)
expect(center).toMatchObject({ width: 640, height: 480 })
// 图片数据先于(或随)属性变化发给浏览器,同一张图只发一次
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' })
const up = pictures()
const pan = runner.next('device', (m) => m.device === 'camera' && m.action === 'pan')
runner.send({ type: 'action', id: id('btn_up'), action: 'click' })
expect((await pan).data).toEqual([3, 0])
expect(imageOf(await up).key).not.toBe(center.key)
const intruder = pictures()
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] })
const withIntruder = imageOf(await intruder)
const saved = runner.next('imageSaved')
runner.send({ type: 'action', id: id('btn_camera'), action: 'click' })
const photo = await saved
expect(photo.path).toMatch(/^Camera\/\d{8}_\d{2} \d{2} \d{2}\.jpg$/)
expect(photo.image).toBe(withIntruder.key)
}, 30000)
test('项目4 任务3:开报警灯——实训台 DO0 动作,窗体上的报警灯亮', async () => {
const { runner, id } = await startSample('p4-t3-报警灯')
await openAdam4150(runner, id)
const relay = runner.next('device', (m) => m.device === 'adam4150')
const lamp = runner.next('patch', (m) => m.changes.some((c) => c.id === id('alarm1') && c.prop === 'IsOn' && c.value === true))
runner.send({ type: 'action', id: id('btn_alarmOpen'), action: 'click' })
expect((await relay).data).toEqual([0, 1])
await lamp
}, 30000)
test('项目5 任务1:有火焰时显示"发现火情!"', async () => {
const { runner, id } = await startSample('p5-t1-火焰')
await openAdam4150(runner, id)
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, true, false, false] })
await runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === '发现火情!'), 5000)
}, 30000)
test('项目5 任务2:线程实时显示烟雾数据;关闭时线程挂起、再打开时继续;关闭窗体后程序正常结束', async () => {
const { runner, id, form } = await startSample('p5-t2-烟雾')
// 改写后的代码不再用过时的 API,不应出现"已过时"警告
expect((await runner.waitFor('compiled')).diagnostics).toEqual([])
const changed = (name: string, match: (value: unknown) => boolean = () => true, timeout = 5000) =>
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && match(c.value)), timeout)
const normal = changed('txt_even', (v) => v === '正常')
await openAdam4150(runner, id)
await normal
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, true, false] })
await changed('txt_even', (v) => v === '发现烟情')
// 关闭:线程挂起,时间不再更新
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('btn_open', (v) => v === '开启')
await expect(changed('txt_time', undefined, 3000)).rejects.toThrow('超时')
// 再打开:线程继续
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('txt_time', undefined, 3000)
// 挂起后关闭窗体:先 Resume 再 Abort,程序正常结束
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('btn_open', (v) => v === '开启')
runner.send({ type: 'action', id: form.id, action: 'close' })
expect((await runner.finished()).code).toBe(0)
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
}, 30000)
test('项目5 任务2:照书上直接 Abort 挂起的线程,和 .NET Framework 一样报错,并提示先 Resume', async () => {
const dir = await prepareSample('p5-t2-烟雾')
const file = `${dir}/Form1.cs`
const code = await Bun.file(file).text()
await Bun.write(file, code.replace(/if \(thd\.ThreadState == ThreadState\.Suspended\)\s*\{\s*thd\.Resume\(\);\s*\}/, ''))
runner = await Runner.start(dir)
const { form } = await runner.waitFor('formShown')
const id = (name: string) => findByName(form, name).id
await openAdam4150(runner, id)
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === id('btn_open') && c.value === '开启'))
runner.send({ type: 'action', id: form.id, action: 'close' })
const error = await runner.next('exception')
expect(error.message).toContain('ThreadStateException')
expect(error.message).toContain('先调用 Resume()')
}, 30000)
/** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */
async function runMain(name: string, body: string, usings = '', dbDir?: string) {
const dir = resolve(ROOT, '.runs', `test-${name}`)
await rm(dir, { recursive: true, force: true })
await Bun.write(
`${dir}/Program.cs`,
`using System;\nusing System.IO;\nusing System.Linq;\n${usings}\nstatic class Program\n{\n static void Main()\n {\n${body}\n }\n}\n`,
)
runner = await Runner.start(dir, dbDir)
return { runner, dir }
}
test('旧版线程 API:Suspend / Resume / Abort、线程自己挂起、休眠中被终止、ThreadState', async () => {
const { runner } = await runMain(
'legacy-threads',
`
int count = 0;
Thread worker = new Thread(() => { while (true) { count++; Thread.Sleep(20); } });
Console.WriteLine("state0=" + worker.ThreadState);
worker.Start();
Thread.Sleep(200);
worker.Suspend();
Thread.Sleep(100);
int frozen = count;
Console.WriteLine("state1=" + worker.ThreadState);
Thread.Sleep(200);
Console.WriteLine("frozen=" + (count == frozen));
try { worker.Abort(); } catch (ThreadStateException) { Console.WriteLine("abortSuspended=error"); }
worker.Resume();
Console.WriteLine("joined=" + worker.Join(1000) + "," + worker.ThreadState);
Thread self = null;
self = new Thread(() => { self.Suspend(); Console.WriteLine("self=resumed"); });
self.Start();
Thread.Sleep(100);
Console.WriteLine("selfState=" + self.ThreadState);
self.Resume();
self.Join(1000);
Thread sleeper = new Thread(new ThreadStart(Sleepy));
sleeper.Start();
Thread.Sleep(100);
try { sleeper.Resume(); } catch (ThreadStateException) { Console.WriteLine("resumeRunning=error"); }
sleeper.Abort();
Console.WriteLine("sleeperJoined=" + sleeper.Join(1000));
Thread unstarted = new Thread(Sleepy);
unstarted.Abort();
Console.WriteLine("unstarted=" + unstarted.ThreadState);
var param = new Thread(o => Console.WriteLine("param=" + o));
param.Start(42);
param.Join();
}
static void Sleepy()
{
Thread.Sleep(100000);
Console.WriteLine("sleeper=woke");`,
'using System.Threading;',
)
expect((await runner.waitFor('compiled')).ok).toBe(true)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
for (const line of [
'state0=Unstarted',
'state1=Suspended',
'frozen=True',
'abortSuspended=error',
'joined=True,Stopped',
'selfState=Suspended',
'self=resumed',
'resumeRunning=error',
'sleeperJoined=True',
'unstarted=Unstarted, AbortRequested',
'param=42',
]) {
expect(output).toContain(line)
}
expect(output).not.toContain('sleeper=woke')
}, 30000)
test('ListBox:点某一项先选中(SelectedIndexChanged)再触发 Click,Text 是选中项;双击触发 DoubleClick', async () => {
const { runner } = await runMain(
'listbox',
`
var form = new Form();
var list = new ListBox { Name = "list" };
list.Items.AddRange(new object[] { "甲", "乙", "丙" });
list.SelectedIndexChanged += (s, e) => Console.WriteLine("changed=" + list.SelectedIndex + "," + list.Text);
list.Click += (s, e) => Console.WriteLine("click");
list.DoubleClick += (s, e) => { Console.WriteLine("double=" + list.SelectedItem); list.Items.RemoveAt(1); };
form.Controls.Add(list);
Application.Run(form);`,
'using System.Windows.Forms;',
)
const { form } = await runner.waitFor('formShown')
const list = findByName(form, 'list')
expect(list.props.Items).toEqual(['甲', '乙', '丙'])
runner.send({ type: 'action', id: list.id, action: 'select', index: 1 })
await runner.waitFor('console', (m) => m.text === 'click\n')
runner.send({ type: 'action', id: list.id, action: 'doubleClick' })
// 删掉选中的项后取消选中,浏览器收到新的列表
const patch = await runner.next('patch', (m) => m.changes.some((c) => c.prop === 'Items'))
expect(patch.changes).toContainEqual({ id: list.id, prop: 'SelectedIndex', value: -1 })
expect(patch.changes).toContainEqual({ id: list.id, prop: 'Items', value: ['甲', '丙'] })
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toBe('changed=1,乙\nclick\ndouble=乙\n')
}, 30000)
test('图片:程序所在目录可写,Image.Save / FromFile 往返,文件夹不存在时和 GDI+ 一样报一般性错误', async () => {
const { runner } = await runMain(
'image',
`
string dir = AppDomain.CurrentDomain.BaseDirectory;
Console.WriteLine("base=" + dir.EndsWith("/bin/Debug/") + "," + (Directory.GetCurrentDirectory() + "/" == dir));
// 1×1 的 PNG
byte[] png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
Image image = Image.FromStream(new MemoryStream(png));
Directory.CreateDirectory(dir + "Pics");
image.Save(dir + "Pics/a.png", ImageFormat.Png);
Image back = Image.FromFile("Pics/a.png");
Console.WriteLine("size=" + back.Width + "x" + back.Height + "," + back.RawFormat);
try { image.Save(dir + "NoSuchDir/b.png"); }
catch (System.Runtime.InteropServices.ExternalException ex) { Console.WriteLine("error=" + ex.Message); }`,
'using System.Drawing;\nusing System.Drawing.Imaging;',
)
expect((await runner.waitFor('compiled')).diagnostics.filter((d) => d.severity === 'error')).toEqual([])
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toContain('base=True,True')
expect(output).toContain('size=1x1,Png')
expect(output).toContain('error=GDI+ 中发生一般性错误。')
expect(runner.messages.filter((m) => m.type === 'imageSaved').map((m) => (m as { path: string }).path)).toEqual(['Pics/a.png'])
}, 30000)
test('数据库:照书上的写法建库建表、插入、查询、SqlCommandBuilder 修改、删除、按时间段查询;报错和 SQL Server 一样', async () => {
const dbDir = resolve(ROOT, '.runs', 'test-db-data')
await rm(dbDir, { recursive: true, force: true })
await mkdir(dbDir, { recursive: true })
const { runner } = await runMain(
'database',
`
var master = new SqlConnection(@"Data Source= PC-20150908SCJF\\SQLSERVER1026;User ID=sa;Password=123456");
master.Open();
new SqlCommand("CREATE DATABASE 环境数据表", master).ExecuteNonQuery();
master.Close();
string cs = @"Data Source=.;Initial Catalog=环境数据表;User ID=sa;Password=123456";
var conn = new SqlConnection(cs);
conn.Open();
new SqlCommand("CREATE TABLE CampusEnvironment (ID int IDENTITY(1,1) PRIMARY KEY, light varchar(50), humidity varchar(50), temperature varchar(50), addtime datetime)", conn).ExecuteNonQuery();
// 书上的写法:不写列名,值的顺序是温度、湿度、光照(和建表的列顺序不一样)
string temperature = "25", humidity = "60", light = "500";
int n = new SqlCommand("insert into CampusEnvironment values (" + temperature + "," + humidity + "," + light + ", '" + new DateTime(2024, 1, 16, 9, 5, 3).ToString() + "')", conn).ExecuteNonQuery();
Console.WriteLine("inserted=" + n);
new SqlCommand("insert into CampusEnvironment (light, humidity, temperature, addtime) values ('300', '55', '18', '2024/1/20 14:42:00')", conn).ExecuteNonQuery();
new SqlCommand("insert into CampusEnvironment (light, humidity, temperature, addtime) values ('100', '50', '20', '2024-01-25 08:00:00')", conn).ExecuteNonQuery();
var adapter = new SqlDataAdapter(new SqlCommand("select ID as '编号',temperature as '温度',humidity as '湿度',light as '光照度', addtime as '时间' from CampusEnvironment", conn));
var ds = new DataSet();
adapter.Fill(ds);
var t = ds.Tables[0];
Console.WriteLine("columns=" + string.Join(",", t.Columns.Cast<DataColumn>().Select(c => c.ColumnName + ":" + c.DataType.Name)));
Console.WriteLine("row0=" + string.Join("|", t.Rows[0].ItemArray));
// 修改:改 DataTable 里的一格,SqlCommandBuilder 生成 UPDATE
var builder = new SqlCommandBuilder(adapter);
adapter.UpdateCommand = builder.GetUpdateCommand();
t.Rows[0]["温度"] = "26";
Console.WriteLine("updated=" + adapter.Update(t));
Console.WriteLine("check=" + new SqlCommand("select temperature from CampusEnvironment where ID=1", conn).ExecuteScalar());
Console.WriteLine("deleted=" + new SqlCommand("delete from CampusEnvironment where id=" + t.Rows[2][0].ToString(), conn).ExecuteNonQuery());
// 任务 3:BETWEEN 拼接 DateTime.ToString()(中文格式)
var ds2 = new DataSet();
new SqlDataAdapter(new SqlCommand("select * from CampusEnvironment where addtime BETWEEN '" + new DateTime(2024, 1, 16).ToString() + "' AND '" + new DateTime(2024, 1, 18).ToString() + "'", conn)).Fill(ds2);
Console.WriteLine("between=" + ds2.Tables[0].Rows.Count);
var reader = new SqlCommand("select count(*), max(addtime), CONVERT(varchar(10), addtime, 120) as d from CampusEnvironment group by CONVERT(varchar(10), addtime, 120) order by d", conn).ExecuteReader();
while (reader.Read()) Console.WriteLine("group=" + reader.GetInt32(0) + "," + reader.GetDateTime(1).ToString("HH:mm") + "," + reader["d"]);
reader.Close();
foreach (var sql in new[] { "select * from NoSuchTable", "selec * from CampusEnvironment", "insert into CampusEnvironment values (1, 2)", "insert into CampusEnvironment (ID, light) values (5, '1')", "select nosuch from CampusEnvironment", "insert into CampusEnvironment (addtime) values ('abc')" })
{
try { new SqlCommand(sql, conn).ExecuteNonQuery(); }
catch (SqlException ex) { Console.WriteLine("error=" + ex.Number + " " + ex.Message); }
}
try { new SqlConnection("Data Source=.;Initial Catalog=NoDb").Open(); }
catch (SqlException ex) { Console.WriteLine("error=" + ex.Number + " " + ex.Message); }`,
'using System.Data;\nusing System.Data.SqlClient;',
dbDir,
)
const compiled = await runner.waitFor('compiled')
expect(compiled.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
const errors = runner.messages.filter((m) => m.type === 'exception')
expect(errors).toEqual([])
for (const line of [
'inserted=1',
'columns=编号:Int32,温度:String,湿度:String,光照度:String,时间:DateTime',
// 书上的列顺序错位:光照存了温度的值
'row0=1|500|60|25|2024/1/16 9:05:03',
'updated=1',
'check=26',
'deleted=1',
'between=1',
'group=1,09:05,2024-01-16',
'group=1,14:42,2024-01-20',
"error=208 对象名 'NoSuchTable' 无效。",
"error=102 'selec' 附近有语法错误。",
'error=213 列名或所提供值的数目与表定义不匹配。',
"error=544 当 IDENTITY_INSERT 设置为 OFF 时,不能为表 'CampusEnvironment' 中的标识列插入显式值。",
"error=207 列名 'nosuch' 无效。",
'error=241 从字符串转换日期和/或时间时,转换失败。',
'error=4060 无法打开登录所请求的数据库 "NoDb"。登录失败。',
]) {
expect(output).toContain(line)
}
}, 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(
'sandbox',
`
Console.WriteLine("passwd=" + File.Exists("/etc/passwd"));
Console.WriteLine("runs=" + Directory.Exists(${JSON.stringify(config.runsDir)}));
Console.WriteLine("home=" + Directory.Exists("/home"));
Console.WriteLine("secret=" + (Environment.GetEnvironmentVariable("SIM_TEST_SECRET") ?? "null"));
Console.WriteLine("procs=" + Directory.GetDirectories("/proc").Count(d => char.IsDigit(Path.GetFileName(d)[0])));
// 工程目录在沙箱里挂在 /sim/app(当前目录是可写的程序目录 bin/Debug,不是工程目录)
try { File.WriteAllText("/sim/app/hack.txt", "x"); Console.WriteLine("write=ok"); } catch (Exception) { Console.WriteLine("write=denied"); }
File.WriteAllText("/tmp/ok.txt", "x");
Console.WriteLine("tmp=" + File.Exists("/tmp/ok.txt"));`,
)
expect((await runner.waitFor('compiled')).ok).toBe(true)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toContain('passwd=False')
expect(output).toContain('runs=False')
expect(output).toContain('home=False')
expect(output).toContain('secret=null')
// 只能看到 bwrap 和自己
expect(Number(/procs=(\d+)/.exec(output)?.[1])).toBeLessThanOrEqual(2)
expect(output).toContain('write=denied')
expect(output).toContain('tmp=True')
},
30000,
)
test('内存上限:不停分配内存的程序得到 OutOfMemoryException,而不是吃光服务器内存', async () => {
const { runner } = await runMain(
'heap',
`
var blocks = new System.Collections.Generic.List<byte[]>();
try { while (true) blocks.Add(new byte[1 << 20]); }
catch (OutOfMemoryException) { Console.WriteLine("oom=" + blocks.Count); }`,
)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
const megabytes = Number(/oom=(\d+)/.exec(output)?.[1])
expect(megabytes).toBeGreaterThan(50)
expect(megabytes).toBeLessThanOrEqual(config.heapLimitMb)
}, 30000)
test('编译检查:网络、启动程序、反射、dynamic、AppDomain 给出中文报错;AssemblyInfo.cs 里的特性、BaseDirectory 不受影响', async () => {
const dir = resolve(ROOT, '.runs/test-policy')
await rm(dir, { recursive: true, force: true })
await Bun.write(
`${dir}/Program.cs`,
`using System;
using System.Diagnostics;
using System.Net.Http;
static class Program
{
static void Main()
{
Process.Start("sh");
var client = new HttpClient();
typeof(string).GetMethod("Trim", Type.EmptyTypes).Invoke("x", null);
dynamic d = 1;
string path = AppDomain.CurrentDomain.BaseDirectory + "Camera/";
AppDomain.CurrentDomain.GetAssemblies();
}
}
`,
)
await Bun.write(
`${dir}/Properties/AssemblyInfo.cs`,
`using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("WindowsFormsApplication1")]
[assembly: ComVisible(false)]
[assembly: Guid("0a1b2c3d-1111-2222-3333-444455556666")]
`,
)
const result = await compileService.compile(dir)
expect(result.ok).toBe(false)
const denied = result.diagnostics.filter((d) => d.code === 'SIM001')
expect(denied.map((d) => [d.line, d.message])).toEqual([
[8, '平台不允许使用 System.Diagnostics.Process.Start(启动其他程序)'],
[9, '平台不允许使用 System.Net.Http.HttpClient(网络访问)'],
[10, '平台不允许使用 System.Reflection.MethodBase.Invoke(反射)'],
[11, '平台不允许使用 dynamic(dynamic 动态调用)'],
[13, '平台不允许使用 System.AppDomain.GetAssemblies(加载程序集)'],
])
expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([])
}, 30000)
test.skipIf(config.sandbox === 'none')(
'CPU 时间上限:死循环的程序到时被结束,并提示检查死循环',
async () => {
const limit = config.cpuLimitSeconds
config.cpuLimitSeconds = 2
try {
const { runner } = await runMain('cpu', ' long i = 0;\n while (true) i++;')
const { code, signal } = await runner.finished()
expect(cpuLimitMessage(code, signal)).toContain('死循环')
} finally {
config.cpuLimitSeconds = limit
}
},
30000,
)
test('运行时异常:常见的 .NET 异常配中文说明,带出错的文件、行号和方法', async () => {
const { runner } = await runMain(
'exception',
`
Console.WriteLine("start");
int n = int.Parse("abc");`,
)
const error = await runner.next('exception')
expect(error.message).toStartWith('文字转换成数字或日期时格式不对')
expect(error.message).toContain('System.FormatException')
expect(error).toMatchObject({ file: 'Program.cs', line: 11 })
expect(error.detail).toBe('出错位置:Program.cs 第 11 行(Main 方法)')
}, 30000)
test('数据库:DATEADD、DATEDIFF、DATEPART 的日期单位写成关键字(day、hour)', async () => {
const dbDir = resolve(ROOT, '.runs', 'test-dateadd-db')
await rm(dbDir, { recursive: true, force: true })
await mkdir(dbDir, { recursive: true })
const { runner } = await runMain(
'dateadd',
`
var master = new SqlConnection("Data Source=.;User ID=sa");
master.Open();
new SqlCommand("CREATE DATABASE T", master).ExecuteNonQuery();
var conn = new SqlConnection("Data Source=.;Initial Catalog=T");
conn.Open();
foreach (var sql in new[] {
"SELECT DATEADD(hour, 8, DATEADD(day, -7, '2024-01-16'))",
"SELECT DATEDIFF(day, '2024-01-01 23:00', '2024-01-02 01:00')",
"SELECT DATEPART(hh, '2024-01-16 09:05:03')",
})
Console.WriteLine("result=" + new SqlCommand(sql, conn).ExecuteScalar());`,
'using System.Data.SqlClient;',
dbDir,
)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
expect(output).toBe('result=2024/1/9 8:00:00\nresult=1\nresult=9\n')
}, 30000)