试用前准备:全班模拟压测、评分并发 6→16、手动备份与恢复
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- test/classroom.ts:45 个虚拟学生走一遍登录、运行、自测、提交,统计每个环节等多久
- 评分并发可配置(SIM_GRADE_CONCURRENCY,默认 16),提交队列同时评的份数跟着走;
  2 核实测项目 8 任务 3 全班自测从约 4 分钟降到约 1.5 分钟
- 检查的默认等待 2 秒→5 秒,机器忙时不再误判正确的程序
- 删除学生时删除他的数据库(编号会重新用给新学生)
- deploy/backup.sh、deploy/restore.sh:手动备份到宿主机 backups/,恢复前先备份

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-25 20:08:31 +08:00
1 parent e8058376dd
commit 982b1297aa
23 files changed
+479 -25

No files matched your search

+3 -3
View File
@@ -24,7 +24,7 @@ import { languageService } from './language-service'
import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews'
import type { GradingQueue } from './grading/queue'
import { config } from './config'
import { restoreExperimentDatabases, userDatabaseDir } from './databases'
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
import { listSamples, loadChecks, loadSample } from './samples'
import { SqlError, sqlService } from './sql-service'
import { serveStatic } from './static'
@@ -280,8 +280,8 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
return password ? c.json({ password }) : c.json({ message: '学生不存在' }, 404)
})
app.delete('/api/teacher/students/:id', (c) => {
deleteStudent(db, id(c))
app.delete('/api/teacher/students/:id', async (c) => {
if (deleteStudent(db, id(c))) await deleteUserDatabases(id(c))
return c.json({})
})
+38
View File
@@ -0,0 +1,38 @@
// 手动备份:平台数据库(账号、班级、作品、作业、提交、评阅)和每个学生的数据库,导出到一个目录,
// 目录结构和 data/ 一样。服务器上用 deploy/backup.sh 备份、deploy/restore.sh 恢复(见 deploy/README.md)。
import { Database } from 'bun:sqlite'
import { mkdir, readdir, rename, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { config } from './config'
/**
* 把一个 SQLite 数据库完整导出成文件。serialize 在读事务里读出所有页,
* 学生程序或平台正在写入时也能得到一份一致的数据库(直接拷文件可能拷到写了一半的状态)
*/
async function saveCopy(path: string, target: string) {
const source = new Database(path, { readonly: true })
try {
await Bun.write(target, source.serialize())
} finally {
source.close()
}
}
/** 把 data/ 里的数据库导出到 target(已有备份时报错,不覆盖) */
export async function backupTo(target: string) {
if (await Bun.file(join(target, 'iot-sim.db')).exists()) throw new Error(`${target} 里已经有备份`)
// 先写到临时目录,写完再改名,不会留下做了一半的备份
const temp = `${target}.partial`
await rm(temp, { recursive: true, force: true })
await mkdir(temp, { recursive: true })
await saveCopy(join(config.dataDir, 'iot-sim.db'), join(temp, 'iot-sim.db'))
const databases = join(config.dataDir, 'databases')
for (const user of await readdir(databases).catch(() => [] as string[])) {
const files = (await readdir(join(databases, user)).catch(() => [] as string[])).filter((f) => f.endsWith('.db'))
if (files.length > 0) await mkdir(join(temp, 'databases', user), { recursive: true })
for (const file of files) await saveCopy(join(databases, user, file), join(temp, 'databases', user, file))
}
await rm(target, { recursive: true, force: true })
await rename(temp, target)
}
+2 -1
View File
@@ -136,6 +136,7 @@ export function resetPassword(db: Database, studentId: number): string | null {
}
/** 删除学生,连同他的作品 */
/** 删除学生,返回是否删掉了 */
export function deleteStudent(db: Database, studentId: number) {
db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId)
return db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId).changes > 0
}
+6
View File
@@ -26,6 +26,12 @@ export const config = {
cpuLimitSeconds: Number(env.SIM_CPU_LIMIT_SECONDS ?? 120),
/** 全站同时运行的学生程序最多多少个(两个班约 100 人) */
maxRuns: Number(env.SIM_MAX_RUNS ?? 150),
/**
* 全站同时跑多少个评分检查项(提交评分和自测共用)。检查大部分时间在等学生程序的定时器,并发太低时全班排队:
* 2 核实测 45 人同时自测项目 8 任务 3,6 个时最后一人等 4 分钟(CPU 才 28%),16 个时 1.5 分钟(CPU 58%);
* 24 个时 CPU 接近 80%,定时器来不及触发,参考答案被误判不通过(test/classroom.ts)
*/
gradeConcurrency: Number(env.SIM_GRADE_CONCURRENCY ?? 16),
/** 一次运行最长多少分钟,到时自动结束 */
maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60),
/** SQLite 数据库所在目录(账号、班级、学生作品);部署时放在数据卷里 */
+8
View File
@@ -13,6 +13,14 @@ export async function userDatabaseDir(userId: number) {
return dir
}
/**
* 删除用户的全部数据库(删除学生时)。用户编号在删掉最大的编号后会被下一个新用户重新使用,
* 不删的话新学生会看到前一个人的库
*/
export async function deleteUserDatabases(userId: number) {
await rm(resolve(config.dataDir, 'databases', String(userId)), { recursive: true, force: true })
}
/** 实验预置的数据库脚本(samples/<实验>/database.sql),没有时返回 undefined */
export async function experimentScript(sampleId: string): Promise<string | undefined> {
const file = Bun.file(join(config.samplesDir, sampleId, 'database.sql'))
+8 -3
View File
@@ -22,7 +22,8 @@ export type Step =
| { 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 }
/** timed:规则里写了 within(不通过时说明"几秒内") */
| { kind: 'expect'; expect: Expectation; within: number; timed: boolean; desc?: string }
export type Expectation =
/** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,visible → Visible(连同所在的容器都可见),on → IsOn(灯泡、报警灯),value → Value(滑动条) */
@@ -54,8 +55,11 @@ export type Expectation =
| { kind: 'row'; control: string; cells: Record<string, string>; present: boolean }
| { kind: 'console'; text: string }
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
const DEFAULT_WITHIN = 2000
/**
* 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within。条件一满足就往下走,放宽只让不通过的检查多等一会儿;
* 全班同时评分时机器忙,点按钮后查数据库、画图表可能超过 2 秒(test/classroom.ts 实测),所以不能太紧
*/
const DEFAULT_WITHIN = 5000
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', visible: 'Visible', on: 'IsOn', value: 'Value' }
const STRING_PROPS = new Set(['Text', 'LedText'])
@@ -158,6 +162,7 @@ function parseStep(raw: unknown, where: string): Step {
kind: 'expect',
expect: parseExpectation(expectation, where),
within: within === undefined ? DEFAULT_WITHIN : duration(within, where),
timed: within !== undefined,
...(desc === undefined ? {} : { desc: String(desc) }),
}
}
+3 -4
View File
@@ -1,5 +1,5 @@
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;全站同时跑 config.gradeConcurrency 个检查,其余排队。
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
import { rm } from 'node:fs/promises'
import { dirname } from 'node:path'
@@ -9,7 +9,6 @@ import { gradingDatabaseDir } from '../databases'
import type { Check, Expectation, Step } from './checks'
import { ProgramDriver } from './driver'
const GRADE_CONCURRENCY = 6
/** 点数据表格的单元格前,最多等多久让那一行出现(程序可能还在查询数据库、刷新表格) */
const GRID_WAIT = 5000
/** 操作控件前最多等多久让它出现 */
@@ -28,7 +27,7 @@ let running = 0
const waiting: (() => void)[] = []
async function withSlot<T>(task: () => Promise<T>): Promise<T> {
if (running >= GRADE_CONCURRENCY) await new Promise<void>((resolve) => waiting.push(resolve))
if (running >= config.gradeConcurrency) await new Promise<void>((resolve) => waiting.push(resolve))
running++
try {
return await task()
@@ -211,7 +210,7 @@ const quote = (value: unknown) => (value === undefined || value === null || valu
async function expectStep(driver: ProgramDriver, step: Extract<Step, { kind: 'expect' }>) {
const { expect: e, within } = step
const wait = within > 2000 ? `${seconds(within)}内` : ''
const wait = step.timed ? `${seconds(within)}内` : ''
if (e.kind === 'unchanged') {
const node = need(driver, e.control)
+2 -3
View File
@@ -1,4 +1,4 @@
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时评的份数不超过检查项的并发上限,保证评分槽位能用满),结果写回数据库。
// 服务器重启前没评完的提交,启动时继续评。评完调用 onGraded(接着做 AI 评阅)。
import type { Database } from 'bun:sqlite'
import type { SourceFile } from '@iot/shared'
@@ -9,7 +9,6 @@ import { config } from '../config'
import { safeJoin } from '../run-session'
import { grade } from './grader'
const MAX_ACTIVE = 3
/** 把作品写进一个干净的工程目录(评分和自测用) */
export async function writeProject(dir: string, files: SourceFile[]) {
@@ -44,7 +43,7 @@ export function createGradingQueue(db: Database, onGraded: (submissionId: number
}
function pump() {
while (active < MAX_ACTIVE && queue.length > 0) {
while (active < config.gradeConcurrency && queue.length > 0) {
const id = queue.shift()!
active++
void gradeOne(id).finally(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
// 自测:学生提交前自己跑一遍评分,告诉他哪几项不通过、为什么;不计分、不留记录。
// 每人同时只能有一次,结果放在内存里,保留到下一次自测(服务器重启后就没有了)。
// 检查项和提交评分一样受全站并发上限(grader.ts 的 GRADE_CONCURRENCY)约束,机器忙时排队。
// 检查项和提交评分一样受全站并发上限(config.gradeConcurrency)约束,机器忙时排队。
import type { SelfCheck, SourceFile } from '@iot/shared'
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
+10
View File
@@ -1,6 +1,7 @@
import type { ClientMessage, ServerMessage } from '@iot/shared'
import { createAiQueue } from './ai-review'
import { createApp } from './app'
import { backupTo } from './backup'
import { ensureTeacher, tokenUser } from './auth'
import { compileService } from './compile-service'
import { languageService } from './language-service'
@@ -13,6 +14,15 @@ import { cleanupStaleRuns, RunSession } from './run-session'
import { sqlService } from './sql-service'
import { checkSandbox, sandboxError } from './sandbox'
// 手动备份:bun server.js backup <目录>(deploy/backup.sh 调用),导出数据库后退出,不启动服务器
if (process.argv[2] === 'backup') {
const target = process.argv[3]
if (!target) throw new Error('用法:backup <目录>')
await backupTo(target)
console.log(`已备份到 ${target}`)
process.exit(0)
}
await cleanupStaleRuns()
await checkSandbox()
if (sandboxError) console.error(sandboxError)
+16
View File
@@ -499,6 +499,22 @@ describe('数据库页面', () => {
expect((await new Browser().get('/api/db')).status).toBe(401)
}, 60000)
test('删除学生时删除他的库:编号重新用给新学生时看不到前一个人的库', async () => {
const t = await teacher()
const classId = await classWithStudents(t, [{ username: 's1', name: '甲', password: 'p1' }])
const s1 = new Browser()
await s1.login('s1', 'p1')
await s1.post('/api/db/query', { database: 'master', sql: 'CREATE DATABASE 甲的库' })
const [{ id }] = (await t.get(`/api/teacher/classes/${classId}/students`)).body
await t.delete(`/api/teacher/students/${id}`)
await t.post(`/api/teacher/classes/${classId}/import`, { rows: [{ username: 's2', name: '乙', password: 'p2' }] })
expect((await t.get(`/api/teacher/classes/${classId}/students`)).body[0].id).toBe(id)
const s2 = new Browser()
await s2.login('s2', 'p2')
expect((await s2.get('/api/db')).body.databases).toEqual([])
}, 60000)
test('恢复实验初始数据:只替换实验用到的库', async () => {
const t = await teacher()
await t.post('/api/db/query', { database: 'master', sql: 'CREATE DATABASE 我的库' })
+46
View File
@@ -0,0 +1,46 @@
// 手动备份:平台数据库和学生数据库都导出完整(正在使用、数据还在 -wal 文件里时也一样)。
import { afterAll, beforeAll, expect, test } from 'bun:test'
import { Database } from 'bun:sqlite'
import { mkdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { backupTo } from '../src/backup'
import { config } from '../src/config'
import { openDatabase } from '../src/db'
const saved = config.dataDir
const target = `${config.runsDir}/test-backup-copy`
beforeAll(async () => {
config.dataDir = `${config.runsDir}/test-backup-data`
await rm(config.dataDir, { recursive: true, force: true })
await rm(target, { recursive: true, force: true })
})
afterAll(async () => {
await rm(config.dataDir, { recursive: true, force: true })
await rm(target, { recursive: true, force: true })
config.dataDir = saved
})
test('导出平台数据库和每个学生的数据库,不覆盖已有的备份', async () => {
// 平台和学生程序都开着库(WAL 模式,刚写入的数据还在 -wal 文件里)
const db = openDatabase()
db.query(`INSERT INTO users (username, name, password, role, created_at) VALUES ('s1', '学生', '123456', 'student', 0)`).run()
await mkdir(join(config.dataDir, 'databases', '7'), { recursive: true })
const student = new Database(join(config.dataDir, 'databases', '7', 'WisdomCampus.db'), { create: true })
student.run('PRAGMA journal_mode = WAL')
student.run('CREATE TABLE EnvironmentData (id INTEGER PRIMARY KEY, temperature REAL) STRICT')
student.run('INSERT INTO EnvironmentData (temperature) VALUES (25.5), (26)')
await backupTo(target)
await expect(backupTo(target)).rejects.toThrow('已经有备份')
student.close()
db.close()
const mainCopy = new Database(join(target, 'iot-sim.db'), { readonly: true })
expect(mainCopy.query(`SELECT name FROM users WHERE username = 's1'`).get()).toEqual({ name: '学生' })
mainCopy.close()
const studentCopy = new Database(join(target, 'databases', '7', 'WisdomCampus.db'), { readonly: true })
expect(studentCopy.query('SELECT temperature FROM EnvironmentData ORDER BY id').all()).toEqual([{ temperature: 25.5 }, { temperature: 26 }])
studentCopy.close()
})
+254
View File
@@ -0,0 +1,254 @@
// 全班模拟:N 个虚拟学生像在课堂上一样使用平台,按环节统计每个学生等了多久。
// 和 load.ts 不同,它从外面通过 HTTP / WebSocket 访问一台正在运行的服务器,经过登录、语言服务、编译、评分队列的全过程。
//
// 环节(每个环节等全班做完再进入下一个,学生在 SPREAD 秒内陆续开始,像老师说"开始"之后的样子):
// 登录 → 打开作业并保存 → 边写边检查 → 运行(到窗体出现)→ 点按钮到实训台响应 → 自己检查 → 提交并等自动评分
//
// 用法:bun test/classroom.ts [人数,默认 45]
// 环境变量:BASE(默认 http://localhost:3000)、TEACHER_USERNAME / TEACHER_PASSWORD(默认 admin / admin)、
// SPREAD(秒,默认 10)、KEEP=1(结束后保留压测班级和账号)、
// SAMPLE(实验,默认项目 3 任务 2;"点按钮"环节只对默认实验做)、CPUS(和服务器在同一台机器时填服务器用的核,如 0,1,每个环节显示这几个核的平均占用)
// 会建一个"压测班"和 N 个 loadtest 开头的学生账号,结束时删除。按服务器配置测试:taskset -c 0,1 bun src/index.ts
import type { ControlNode, ServerMessage, SourceFile } from '@iot/shared'
const COUNT = Number(process.argv[2] ?? 45)
const BASE = process.env.BASE ?? 'http://localhost:3000'
const SPREAD_MS = Number(process.env.SPREAD ?? 10) * 1000
const SAMPLE = process.env.SAMPLE ?? 'p3-t2-人体红外ZigBee联动'
const PASSWORD = 'loadtest-123'
const CPUS = process.env.CPUS?.split(',').map(Number) ?? []
/** /proc/stat 里这几个核的忙碌时间和总时间 */
async function cpuTimes() {
const stat = await Bun.file('/proc/stat').text()
let busy = 0
let total = 0
for (const cpu of CPUS) {
const fields = new RegExp(`^cpu${cpu} (.*)$`, 'm').exec(stat)![1].trim().split(/\s+/).map(Number)
const idle = fields[3] + fields[4]
const sum = fields.reduce((a, b) => a + b, 0)
busy += sum - idle
total += sum
}
return { busy, total }
}
class Client {
token = ''
async request(method: string, path: string, json?: unknown) {
const headers: Record<string, string> = {}
if (this.token) headers.authorization = `Bearer ${this.token}`
if (json !== undefined) headers['content-type'] = 'application/json'
const response = await fetch(`${BASE}${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) })
const body = (await response.json().catch(() => null)) as any
if (!response.ok) throw new Error(`${method} ${path}:${response.status} ${body?.message ?? ''}`)
return body
}
async login(username: string, password: string) {
this.token = (await this.request('POST', '/api/login', { username, password })).accessToken
}
}
function findId(node: ControlNode, name: string): string | undefined {
if (node.name === name) return node.id
for (const child of node.children) {
const id = findId(child, name)
if (id) return id
}
}
/** 一个学生的运行窗口:WebSocket 连接 + 收到的消息 */
class RunWindow {
private readonly ws: WebSocket
private listeners: ((m: ServerMessage) => void)[] = []
form?: ControlNode
compileMs?: number
constructor(token: string) {
this.ws = new WebSocket(`${BASE.replace(/^http/, 'ws')}/ws?token=${encodeURIComponent(token)}`)
this.ws.onmessage = (event) => {
const message = JSON.parse(String(event.data)) as ServerMessage
if (message.type === 'compiled') this.compileMs = message.ms
for (const listener of this.listeners) listener(message)
}
}
opened() {
return new Promise<void>((resolve, reject) => {
this.ws.onopen = () => resolve()
this.ws.onerror = () => reject(new Error('WebSocket 连接失败'))
})
}
send(message: object) {
this.ws.send(JSON.stringify(message))
}
/** 等到某条消息出现;出错消息直接失败 */
waitFor(match: (m: ServerMessage) => boolean, timeoutMs = 60_000) {
return new Promise<ServerMessage>((resolve, reject) => {
const timer = setTimeout(() => done(new Error('超时')), timeoutMs)
const listener = (m: ServerMessage) => {
if (m.type === 'serverError' || m.type === 'exception') done(new Error(m.message))
else if (m.type === 'compiled' && !m.ok) done(new Error('编译失败'))
else if (match(m)) done(null, m)
}
const done = (error: Error | null, m?: ServerMessage) => {
clearTimeout(timer)
this.listeners = this.listeners.filter((l) => l !== listener)
if (error) reject(error)
else resolve(m!)
}
this.listeners.push(listener)
})
}
action(name: string, action: object) {
this.send({ type: 'action', id: findId(this.form!, name)!, ...action })
}
close() {
this.ws.close()
}
}
interface Student {
username: string
client: Client
files: SourceFile[]
window?: RunWindow
}
const elapsed = async (work: () => Promise<unknown>) => {
const start = performance.now()
await work()
return performance.now() - start
}
/** 一个环节:每个学生在 SPREAD 内随机时刻开始,统计用时和失败 */
async function phase(name: string, students: Student[], work: (s: Student) => Promise<unknown>, spreadMs = SPREAD_MS) {
const times: number[] = []
const errors = new Map<string, number>()
const start = performance.now()
const cpuStart = CPUS.length ? await cpuTimes() : null
await Promise.all(
students.map(async (student) => {
await Bun.sleep(Math.random() * spreadMs)
try {
times.push(await elapsed(() => work(student)))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
errors.set(message, (errors.get(message) ?? 0) + 1)
}
}),
)
times.sort((a, b) => a - b)
const cpuEnd = cpuStart && (await cpuTimes())
const cpu = cpuStart && cpuEnd ? ` CPU ${Math.round(((cpuEnd.busy - cpuStart.busy) / (cpuEnd.total - cpuStart.total)) * 100)}%` : ''
const pct = (p: number) => (times.length ? `${(times[Math.min(times.length - 1, Math.floor((times.length * p) / 100))] / 1000).toFixed(1)}s` : '-')
console.log(
`${name.padEnd(12, ' ')} 成功 ${String(times.length).padStart(3)}/${students.length} 中位数 ${pct(50).padStart(6)} P95 ${pct(95).padStart(6)} 最长 ${pct(100).padStart(6)} 整个环节 ${((performance.now() - start) / 1000).toFixed(1)}s${cpu}`,
)
for (const [message, count] of errors) console.log(` 失败 ${count} 次:${message}`)
}
/** 每秒查一次,直到 done 返回 true */
async function poll(read: () => Promise<boolean>, timeoutMs = 600_000) {
const deadline = Date.now() + timeoutMs
while (!(await read())) {
if (Date.now() > deadline) throw new Error('超时')
await Bun.sleep(1000)
}
}
// ---- 准备:老师建班、导入学生、布置作业 ----
const teacher = new Client()
await teacher.login(process.env.TEACHER_USERNAME ?? 'admin', process.env.TEACHER_PASSWORD ?? 'admin')
const usernames = Array.from({ length: COUNT }, (_, i) => `loadtest${String(i + 1).padStart(3, '0')}`)
const { id: classId } = await teacher.request('POST', '/api/teacher/classes', { name: `压测班 ${new Date().toLocaleString('zh-CN')}` })
await teacher.request('POST', `/api/teacher/classes/${classId}/import`, {
rows: usernames.map((username, i) => ({ username, name: `压测${i + 1}`, password: PASSWORD })),
})
const checks: string[] = await teacher.request('GET', `/api/samples/${encodeURIComponent(SAMPLE)}/checks`)
const template: SourceFile[] = await teacher.request('GET', `/api/samples/${encodeURIComponent(SAMPLE)}`)
const assignment = await teacher.request('POST', '/api/teacher/assignments', {
classId,
sampleId: SAMPLE,
title: '压测',
startMode: 'ui',
template,
scoring: { auto: checks.map(() => 10), manual: [] },
deadline: null,
})
console.log(`${COUNT} 个学生,实验 ${SAMPLE},服务器 ${BASE},每个环节 ${SPREAD_MS / 1000} 秒内陆续开始\n`)
const students: Student[] = usernames.map((username) => ({ username, client: new Client(), files: [] }))
try {
await phase('登录', students, (s) => s.client.login(s.username, PASSWORD))
await phase('打开作业', students, async (s) => {
const { template } = await s.client.request('GET', `/api/assignments/${assignment.id}`)
s.files = template
await s.client.request('PUT', `/api/works/${encodeURIComponent(`assignment:${assignment.id}`)}`, { files: s.files })
})
await phase('边写边检查', students, (s) =>
s.client.request('POST', '/api/language', { type: 'diagnostics', session: 'tab', files: s.files }),
)
// 老师说"运行看看":全班 SPREAD 内先后按 F5
await phase('运行到窗体出现', students, async (s) => {
const window = new RunWindow(s.client.token)
s.window = window
await window.opened()
const shown = window.waitFor((m) => m.type === 'formShown')
window.send({ type: 'run', files: s.files, sampleId: SAMPLE })
window.form = ((await shown) as Extract<ServerMessage, { type: 'formShown' }>).form
})
if (!process.env.SAMPLE) await phase('点按钮到实训台', students.filter((s) => s.window?.form), async (s) => {
const window = s.window!
window.action('com_lamp', { action: 'dropDown' })
window.action('com_lamp', { action: 'select', index: 3 })
window.action('btn_open', { action: 'click' })
await Bun.sleep(300)
// 设备消息由服务器记进实训台状态,再以 board 消息推给浏览器
const lit = window.waitFor((m) => m.type === 'board' && m.state.zigbee[1] === true, 30_000)
window.action('btn_lampOpen', { action: 'click' })
await lit
})
// 程序都还开着,再自己检查一遍(评分进程和正在运行的程序抢 CPU)
await phase('自己检查', students, async (s) => {
await s.client.request('POST', `/api/assignments/${assignment.id}/selfcheck`, { files: s.files })
let state: any
await poll(async () => (state = await s.client.request('GET', '/api/selfcheck'))?.status !== 'running')
// 参考答案应该全部通过;机器太忙时定时器检查可能超时误判
const failed = state.result?.results.filter((r: any) => !r.passed) ?? []
if (state.status !== 'done' || failed.length > 0) throw new Error(`自测没通过:${failed.map((r: any) => r.message).join(';') || state.status}`)
})
for (const s of students) s.window?.close()
await phase('提交到评完', students, async (s) => {
await s.client.request('POST', `/api/assignments/${assignment.id}/submit`, { files: s.files })
await poll(async () => {
const latest = await s.client.request('GET', `/api/assignments/${assignment.id}/submission`)
if (latest.status === 'error') throw new Error('评分出错')
if (latest.status === 'done' && latest.autoScore !== checks.length * 10) throw new Error(`参考答案只得了 ${latest.autoScore} 分`)
return latest.status === 'done'
})
})
} finally {
for (const s of students) s.window?.close()
if (!process.env.KEEP) {
await teacher.request('DELETE', `/api/teacher/assignments/${assignment.id}`)
for (const student of await teacher.request('GET', `/api/teacher/classes/${classId}/students`)) {
await teacher.request('DELETE', `/api/teacher/students/${student.id}`)
}
await teacher.request('DELETE', `/api/teacher/classes/${classId}`)
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ describe('改坏的代码', () => {
const dir = await prepare(id, 'camera', { file: 'Form1.cs', from: 'DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;', to: '' })
const result = await grade(dir, await checksOf(id), id)
expect(result.results.map((r) => r.passed)).toEqual([true, false, true, false])
expect(result.results[1].message).toBe('期望3 秒内 pictureBox1 显示摄像机拍到的图像,实际没有图片')
expect(result.results[1].message).toBe('期望 pictureBox1 显示摄像机拍到的图像,实际没有图片')
}, 60000)
test('数据库:照书上 INSERT 不写列名,温度和光照存反了', async () => {