用 bun fmt 格式化全部代码和文档
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- bunfig.toml 的 [fmt]:不写分号、单引号、一行最长 160,和原来的写法一致
- .prettierignore:docs/教材摘要/ 是书上的摘录,不格式化
- 模板里一行写两条语句的事件处理(@keydown.delete、@dblclick)改成调用方法:去掉分号后拆成两行,Vue 编译不过

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 14:02:33 +08:00
1 parent d4b8bf9241
commit de277d3e9d
102 files changed
+1971 -1059

No files matched your search

+11 -7
View File
@@ -64,7 +64,10 @@ function userPrompt(input: {
? input.result.results.map((r) => `- ${r.passed ? '通过' : '未通过'}:${r.title}${r.message ? `(${r.message})` : ''}`).join('\n')
: '编译没有通过。'
: '(还没有自动评分结果)'
const hints = conventionHints(input.files).map((h) => `- ${h.text}`).join('\n') || '- 无'
const hints =
conventionHints(input.files)
.map((h) => `- ${h.text}`)
.join('\n') || '- 无'
const code = input.files
.filter((f) => f.path.endsWith('.cs'))
.map((f) => `===== ${f.path} =====\n${f.content}`)
@@ -75,8 +78,12 @@ function userPrompt(input: {
${items}
${BOOK_CRITERIA}
${input.bookNames.length ? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
` : ''}
${
input.bookNames.length
? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
`
: ''
}
平台自动评分的结果(功能,供参考,不用再评):
${auto}
@@ -101,10 +108,7 @@ async function callModel(system: string, user: string, screenshots: Screenshot[]
{ role: 'system', content: system },
{
role: 'user',
content: [
{ type: 'text', text: user },
...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } })),
],
content: [{ type: 'text', text: user }, ...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } }))],
},
],
}),
+27 -4
View File
@@ -259,7 +259,6 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
await next()
})
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
app.post('/api/teacher/classes', async (c) => {
@@ -308,7 +307,16 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
)
app.post('/api/teacher/assignments', async (c) => {
const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null; task: string | null }>(c)
const a = await body<{
classId: number
sampleId: string
title: string
startMode: StartMode
template: unknown
scoring: Scoring
deadline: number | null
task: string | null
}>(c)
if (!listClasses(db).some((cls) => cls.id === a.classId)) return c.json({ message: '班级不存在' }, 404)
const checks = typeof a.sampleId === 'string' ? await loadChecks(a.sampleId) : null
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 400)
@@ -352,7 +360,13 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
classSubmissions(db, id(c)).map((s) => {
const review = getReview(db, id(c), s.userId)
const ai = s.submissionId ? getAiReview(db, s.submissionId) : null
return { ...s, manualScore: manualScore(review), review, aiStatus: ai?.status ?? null, aiManual: ai?.status === 'done' ? ai.scores.reduce((a, x) => a + x.score, 0) : null }
return {
...s,
manualScore: manualScore(review),
review,
aiStatus: ai?.status ?? null,
aiManual: ai?.status === 'done' ? ai.scores.reduce((a, x) => a + x.score, 0) : null,
}
}),
),
)
@@ -378,7 +392,16 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
if (!s.submissionId || getReview(db, id(c), s.userId)) continue
const ai = getAiReview(db, s.submissionId)
if (ai?.status !== 'done') continue
if (!saveReview(db, id(c), s.userId, ai.scores.map((x) => x.score), ai.comment).error) adopted++
if (
!saveReview(
db,
id(c),
s.userId,
ai.scores.map((x) => x.score),
ai.comment,
).error
)
adopted++
}
return c.json({ adopted })
})
+17 -3
View File
@@ -121,7 +121,9 @@ export function updateAssignment(
id,
)
// 分值变了,已评分的提交重新算分
for (const s of db.query<{ id: number; result: string }, [number]>(`SELECT id, result FROM submissions WHERE assignment_id = ? AND status = 'done'`).all(id)) {
for (const s of db
.query<{ id: number; result: string }, [number]>(`SELECT id, result FROM submissions WHERE assignment_id = ? AND status = 'done'`)
.all(id)) {
db.query('UPDATE submissions SET auto_score = ? WHERE id = ?').run(autoScore(JSON.parse(s.result), changes.scoring), s.id)
}
return {}
@@ -180,7 +182,10 @@ export function createSubmission(db: Database, assignmentId: number, userId: num
}
export function pendingSubmissionIds(db: Database): number[] {
return db.query<{ id: number }, []>(`SELECT id FROM submissions WHERE status = 'pending' ORDER BY id`).all().map((r) => r.id)
return db
.query<{ id: number }, []>(`SELECT id FROM submissions WHERE status = 'pending' ORDER BY id`)
.all()
.map((r) => r.id)
}
export function submissionForGrading(db: Database, id: number) {
@@ -211,7 +216,16 @@ export function saveGrade(db: Database, id: number, result: GradeResult | null)
export function classSubmissions(db: Database, assignmentId: number) {
return db
.query<
{ user_id: number; username: string; name: string; submission_id: number | null; status: Submission['status'] | null; auto_score: number | null; created_at: number | null; count: number },
{
user_id: number
username: string
name: string
submission_id: number | null
status: Submission['status'] | null
auto_score: number | null
created_at: number | null
count: number
},
[number]
>(
`SELECT u.id AS user_id, u.username, u.name, s.id AS submission_id, s.status, s.auto_score, s.created_at,
+12 -3
View File
@@ -67,7 +67,8 @@ export class Board {
* 恢复真实时间是 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 (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 = {
@@ -124,6 +125,14 @@ export class Board {
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')
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
.format(now)
.replace(' ', 'T')
}
+1 -3
View File
@@ -68,9 +68,7 @@ export function deleteClass(db: Database, id: number): { error?: string } {
}
export function listStudents(db: Database, classId: number): Student[] {
return db
.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
.all(classId)
return db.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`).all(classId)
}
/**
+15 -3
View File
@@ -61,7 +61,15 @@ export type Expectation =
*/
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 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'])
const NUMBER_PROPS = new Set(['Value'])
@@ -96,7 +104,8 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
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 ('window' in raw) return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
if ('window' in raw)
return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
if ('exited' in raw) {
if (raw.exited !== true) throw new Error(`${where}:exited 只能写 true`)
return { kind: 'exited' }
@@ -122,7 +131,10 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
return { kind: 'row', control, cells, present: 'row' in raw }
}
// image: true 或 image: 资源名;background: 资源名
for (const [key, prop] of [['image', 'Image'], ['background', 'BackgroundImage']] as const) {
for (const [key, prop] of [
['image', 'Image'],
['background', 'BackgroundImage'],
] as const) {
if (!(key in raw)) continue
const value = raw[key]
if (value === true) return { kind: 'image', control, prop }
+25 -8
View File
@@ -135,7 +135,8 @@ async function runStep(driver: ProgramDriver, step: Step) {
const index = items.indexOf(step.item)
if (index < 0) {
// 串口列表是空的:最常见的原因是没有填充
const hint = items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
const hint =
items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})${hint}`)
}
driver.act(node, { action: 'select', index })
@@ -151,7 +152,14 @@ async function runStep(driver: ProgramDriver, step: Step) {
const rows = gridRows(node)
if (step.row >= rows.length) throw new Failure(`数据表格 ${step.control} 里没有第 ${step.row + 1} 行(现在有 ${rows.length} 行数据)`)
const column = typeof step.column === 'number' ? step.column : gridColumns(node).findIndex((c) => c.header === step.column || c.name === step.column)
if (column < 0) throw new Failure(`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${gridColumns(node).map((c) => c.header).join('、') || '没有'})`)
if (column < 0)
throw new Failure(
`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${
gridColumns(node)
.map((c) => c.header)
.join('、') || '没有'
})`,
)
driver.act(node, { action: 'cellClick', row: step.row, column })
if (step.kind === 'edit') driver.act(node, { action: 'cellEdit', row: step.row, column, value: step.value })
await driver.settle()
@@ -302,8 +310,10 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
case 'prop': {
const actual = need(driver, e.control).props[e.prop]
if (e.prop === 'Text' || e.prop === 'LedText') return `期望${prefix} ${e.control} ${desc ?? `显示"${e.value}"`},实际是${quote(actual)}`
if (e.prop === 'Enabled') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
if (e.prop === 'Visible') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
if (e.prop === 'Enabled')
return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
if (e.prop === 'Visible')
return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
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)}`
@@ -312,7 +322,8 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
case 'contains': {
const node = need(driver, e.control)
const items = listItems(node)
if (items) return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
if (items)
return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(node.props.Text)}`
}
case 'matches':
@@ -354,12 +365,18 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
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(',')
const wanted = Object.entries(e.cells)
.map(([k, v]) => `${k}=${v}`)
.join(',')
const columns = gridColumns(node)
const missing = Object.keys(e.cells).filter((key) => !columns.some((c) => c.header === key || c.name === key))
if (missing.length) return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
if (missing.length)
return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
const rows = gridRows(node)
const shown = rows.slice(0, 5).map((r) => r.join(' | ')).join(';')
const shown = rows
.slice(0, 5)
.map((r) => r.join(' | '))
.join(';')
return `期望${prefix}数据表格 ${e.control} ${desc ?? (e.present ? `有一行 ${wanted}` : `没有 ${wanted} 的行`)},实际表格里${rows.length ? `是:${shown}${rows.length > 5 ? ' ……' : ''}` : '没有数据'}`
}
}
-1
View File
@@ -9,7 +9,6 @@ import { config } from '../config'
import { safeJoin } from '../run-session'
import { grade } from './grader'
/** 把作品写进一个干净的工程目录(评分和自测用) */
export async function writeProject(dir: string, files: SourceFile[]) {
await rm(dir, { recursive: true, force: true })
+9 -2
View File
@@ -30,7 +30,11 @@ if (sandboxError) console.error(sandboxError)
const db = openDatabase()
ensureTeacher(db)
const aiQueue = createAiQueue(db)
const app = createApp(db, createGradingQueue(db, (id) => aiQueue.enqueue(id)), aiQueue)
const app = createApp(
db,
createGradingQueue(db, (id) => aiQueue.enqueue(id)),
aiQueue,
)
interface SocketData {
userId?: number
@@ -58,7 +62,10 @@ const server = Bun.serve({
sendPings: true,
open(ws) {
const userId = ws.data.userId!
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId))
ws.data.session = new RunSession(
(message) => ws.send(JSON.stringify(message)),
() => userDatabaseDir(userId),
)
},
async message(ws, raw) {
let message: ClientMessage
+17 -5
View File
@@ -39,7 +39,8 @@ export function saveReview(db: Database, assignmentId: number, userId: number, s
if (!Array.isArray(scores) || scores.length !== manual.length) return { error: `老师评分项应有 ${manual.length} 项` }
for (const [i, s] of scores.entries()) {
if (s === null) continue
if (typeof s !== 'number' || !Number.isFinite(s) || s < 0 || s > manual[i].points) return { error: `"${manual[i].title}"的得分要在 0 到 ${manual[i].points} 之间` }
if (typeof s !== 'number' || !Number.isFinite(s) || s < 0 || s > manual[i].points)
return { error: `"${manual[i].title}"的得分要在 0 到 ${manual[i].points} 之间` }
}
db.query(
`INSERT INTO reviews (assignment_id, user_id, scores, comment, updated_at) VALUES (?, ?, ?, ?, ?)
@@ -53,10 +54,13 @@ export function studentDetail(db: Database, assignmentId: number, userId: number
const student = db.query<{ id: number; username: string; name: string }, [number]>('SELECT id, username, name FROM users WHERE id = ?').get(userId)
if (!student) return null
const latest = latestSubmission(db, assignmentId, userId)
const row = latest && db.query<{ files: string; screenshots: string | null }, [number]>('SELECT files, screenshots FROM submissions WHERE id = ?').get(latest.id)
const row =
latest && db.query<{ files: string; screenshots: string | null }, [number]>('SELECT files, screenshots FROM submissions WHERE id = ?').get(latest.id)
const files = row ? (JSON.parse(row.files) as SourceFile[]) : null
const screenshots = row?.screenshots ? (JSON.parse(row.screenshots) as Screenshot[]) : []
const count = db.query<{ n: number }, [number, number]>('SELECT COUNT(*) AS n FROM submissions WHERE assignment_id = ? AND user_id = ?').get(assignmentId, userId)!.n
const count = db
.query<{ n: number }, [number, number]>('SELECT COUNT(*) AS n FROM submissions WHERE assignment_id = ? AND user_id = ?')
.get(assignmentId, userId)!.n
return {
student,
submission: latest,
@@ -77,7 +81,13 @@ export function submissionFiles(db: Database, submissionId: number) {
)
.get(submissionId)
if (!row) return null
return { files: JSON.parse(row.files) as SourceFile[], assignmentId: row.assignment_id, assignmentTitle: row.title, studentName: row.name, username: row.username }
return {
files: JSON.parse(row.files) as SourceFile[],
assignmentId: row.assignment_id,
assignmentTitle: row.title,
studentName: row.name,
username: row.username,
}
}
/** 全班成绩表:每个学生在每个作业上的自动评分、老师评分和总分 */
@@ -91,7 +101,9 @@ export function gradebook(db: Database, classId: number) {
return { id: a.id, title: a.title, scoring, total: sum(scoring.auto) + sum(scoring.manual.map((m) => m.points)) }
})
const students = db
.query<{ id: number; username: string; name: string }, [number]>(`SELECT id, username, name FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
.query<{ id: number; username: string; name: string }, [number]>(
`SELECT id, username, name FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`,
)
.all(classId)
return {
assignments: assignments.map(({ id, title, total }) => ({ id, title, total })),
+30 -11
View File
@@ -75,13 +75,20 @@ export function runCommand(projectDir: string, extraEnv: Record<string, string>,
const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: DB_DIR } : {}) })
const cmd = [
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
'nice', '-n', '10',
'nice',
'-n',
'10',
// CPU 时间用完直接 SIGKILL 结束(软硬上限相同)。软上限更小时内核先发 SIGXCPU,它默认生成 core dump:
// 学生每写一次死循环就留下一个转储,本机桌面还会弹出"程序崩溃"
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`, '--core=0',
'prlimit',
`--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`,
'--core=0',
...bwrapArgs(projectDir),
...(dbDir ? ['--bind', dbDir, DB_DIR] : []),
'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR,
'dotnet',
`${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`,
'run',
APP_DIR,
]
return { cmd, env }
}
@@ -105,15 +112,27 @@ function bwrapArgs(projectDir: string) {
'--unshare-all',
'--die-with-parent',
'--new-session',
'--ro-bind', '/usr', '/usr',
'--ro-bind',
'/usr',
'/usr',
...rootLinks(),
'--ro-bind-try', '/etc/localtime', '/etc/localtime',
'--ro-bind-try',
'/etc/localtime',
'/etc/localtime',
...dotnetMounts(),
'--proc', '/proc',
'--dev', '/dev',
'--tmpfs', '/tmp',
'--ro-bind', dirname(config.runnerDll), RUNNER_DIR,
'--ro-bind', projectDir, APP_DIR,
'--chdir', APP_DIR,
'--proc',
'/proc',
'--dev',
'/dev',
'--tmpfs',
'/tmp',
'--ro-bind',
dirname(config.runnerDll),
RUNNER_DIR,
'--ro-bind',
projectDir,
APP_DIR,
'--chdir',
APP_DIR,
]
}
+8 -4
View File
@@ -25,9 +25,7 @@ export interface SqlColumnInfo {
}
export type SqlRequest =
| { type: 'exec'; dir: string; database: string; sql: string }
| { type: 'script'; dir: string; sql: string }
| { type: 'explorer'; dir: string }
{ type: 'exec'; dir: string; database: string; sql: string } | { type: 'script'; dir: string; sql: string } | { type: 'explorer'; dir: string }
/** SQL 报错(编号和信息与 SQL Server 一致) */
export class SqlError extends Error {
@@ -78,7 +76,13 @@ class SqlService {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'inherit',
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', TZ: config.timeZone },
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: process.env.HOME ?? '/tmp',
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
DOTNET_NOLOGO: '1',
TZ: config.timeZone,
},
})
this.proc = proc
this.ready = new Promise<void>((resolveReady) => {
+108 -20
View File
@@ -33,9 +33,7 @@ class Browser {
const headers: Record<string, string> = { 'x-real-ip': this.ip }
if (this.accessToken) headers.authorization = `Bearer ${this.accessToken}`
if (json !== undefined) headers['content-type'] = 'application/json'
const response = await app.fetch(
new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }),
)
const response = await app.fetch(new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }))
const body = (await response.json()) as any
if (body?.accessToken) {
this.accessToken = body.accessToken
@@ -418,7 +416,13 @@ describe('作业', () => {
describe('评阅与成绩', () => {
const SAMPLE = 'p4-t3-报警灯'
const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }, { title: '注释', points: 5 }] }
const scoring = {
auto: [10, 10],
manual: [
{ title: '窗体界面', points: 10 },
{ title: '注释', points: 5 },
],
}
async function submitted() {
const t = await teacher()
@@ -427,7 +431,15 @@ describe('评阅与成绩', () => {
{ username: 's2', name: '乙', password: 'p2' },
])
const files = (await loadSample(SAMPLE))!
const { body } = await t.post('/api/teacher/assignments', { classId, sampleId: SAMPLE, title: '报警灯', startMode: 'ui', template: files, scoring, deadline: null })
const { body } = await t.post('/api/teacher/assignments', {
classId,
sampleId: SAMPLE,
title: '报警灯',
startMode: 'ui',
template: files,
scoring,
deadline: null,
})
const student = new Browser()
await student.login('s1', 'p1')
await student.post(`/api/assignments/${body.id}/submit`, { files })
@@ -452,7 +464,11 @@ describe('评阅与成绩', () => {
expect((await student.get(`/api/assignments/${assignmentId}`)).body.review).toMatchObject({ scores: [8, null], comment: '界面整齐,注释还要补' })
expect((await student.get('/api/assignments')).body[0].manualScore).toBe(8)
expect((await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body[0]).toMatchObject({ username: 's1', manualScore: 8, review: { comment: '界面整齐,注释还要补' } })
expect((await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body[0]).toMatchObject({
username: 's1',
manualScore: 8,
review: { comment: '界面整齐,注释还要补' },
})
}, 60000)
test('成绩表:总分 = 自动评分 + 老师评分;没提交的没有总分', async () => {
@@ -469,7 +485,11 @@ describe('评阅与成绩', () => {
test('老师减少评分项后,已打的分按新的评分项对齐', async () => {
const { t, assignmentId, userId } = await submitted()
await t.put(`/api/teacher/assignments/${assignmentId}/students/${userId}/review`, { scores: [8, 4], comment: '' })
await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '报警灯', deadline: null, scoring: { auto: [10, 10], manual: [{ title: '窗体界面', points: 5 }] } })
await t.patch(`/api/teacher/assignments/${assignmentId}`, {
title: '报警灯',
deadline: null,
scoring: { auto: [10, 10], manual: [{ title: '窗体界面', points: 5 }] },
})
expect((await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body.review.scores).toEqual([5])
}, 60000)
@@ -487,7 +507,12 @@ describe('语言服务', () => {
test('代码补全、边写边检查(中文提示);请求格式不对拒绝;没登录不能用', async () => {
const t = await teacher()
const files = [{ path: 'Program.cs', content: 'using System.Windows.Forms;\nstatic class P\n{\n static void Main()\n {\n var b = new Button();\n b.\n }\n}\n' }]
const files = [
{
path: 'Program.cs',
content: 'using System.Windows.Forms;\nstatic class P\n{\n static void Main()\n {\n var b = new Button();\n b.\n }\n}\n',
},
]
const completions = await t.post('/api/language', { type: 'complete', session: 'tab1', files, file: 'Program.cs', line: 7, column: 11 })
expect(completions.body.items.map((i: any) => i.label)).toEqual(expect.arrayContaining(['Text', 'Click', 'Enabled']))
@@ -500,7 +525,13 @@ describe('语言服务', () => {
test('平台支持的旧版线程 API(Suspend / Resume / Abort)不提示"已过时"', async () => {
const t = await teacher()
const files = [{ path: 'Program.cs', content: 'using System.Threading;\nstatic class P\n{\n static void Main()\n {\n var t = new Thread(() => Thread.Sleep(10));\n t.Start();\n t.Suspend();\n t.Resume();\n t.Abort();\n }\n}\n' }]
const files = [
{
path: 'Program.cs',
content:
'using System.Threading;\nstatic class P\n{\n static void Main()\n {\n var t = new Thread(() => Thread.Sleep(10));\n t.Start();\n t.Suspend();\n t.Resume();\n t.Abort();\n }\n}\n',
},
]
const check = await t.post('/api/language', { type: 'diagnostics', session: 'tab2', files })
expect(check.body.diagnostics).toEqual([])
}, 60000)
@@ -529,16 +560,40 @@ describe('数据库页面', () => {
database: '测试库',
sql: "CREATE TABLE T (ID int IDENTITY(1,1) PRIMARY KEY, name nvarchar(20)); INSERT INTO T (name) VALUES (N'张三'), (N'李四'); SELECT * FROM T WHERE name LIKE N'张%'",
})
expect(created.body).toMatchObject({ type: 'result', database: '测试库', affected: 2, results: [{ columns: [{ name: 'ID', type: 'int' }, { name: 'name', type: 'nvarchar(20)' }], rows: [[1, '张三']], total: 1 }] })
expect(created.body).toMatchObject({
type: 'result',
database: '测试库',
affected: 2,
results: [
{
columns: [
{ name: 'ID', type: 'int' },
{ name: 'name', type: 'nvarchar(20)' },
],
rows: [[1, '张三']],
total: 1,
},
],
})
const error = await t.post('/api/db/query', { database: '测试库', sql: 'SELECT * FROM 不存在的表' })
expect(error.body).toEqual({ type: 'error', number: 208, message: "对象名 '不存在的表' 无效。", line: 1 })
const explorer = (await t.get('/api/db')).body
expect(explorer.databases).toEqual([{ name: '测试库', tables: [{ name: 'T', columns: [
{ name: 'ID', type: 'int', nullable: false, identity: true, primaryKey: true },
{ name: 'name', type: 'nvarchar(20)', nullable: true, identity: false, primaryKey: false },
] }] }])
expect(explorer.databases).toEqual([
{
name: '测试库',
tables: [
{
name: 'T',
columns: [
{ name: 'ID', type: 'int', nullable: false, identity: true, primaryKey: true },
{ name: 'name', type: 'nvarchar(20)', nullable: true, identity: false, primaryKey: false },
],
},
],
},
])
expect((await student.get('/api/db')).body.databases).toEqual([])
expect((await new Browser().get('/api/db')).status).toBe(401)
}, 60000)
@@ -573,7 +628,13 @@ describe('数据库页面', () => {
describe('AI 评阅', () => {
const SAMPLE = 'p4-t3-报警灯'
const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }, { title: '注释规范', points: 5 }] }
const scoring = {
auto: [10, 10],
manual: [
{ title: '窗体界面', points: 10 },
{ title: '注释规范', points: 5 },
],
}
/** 假的模型服务:记下收到的请求,返回固定的评分(第二项故意超出满分,检查服务器会限制) */
const requests: any[] = []
let fake: ReturnType<typeof Bun.serve>
@@ -584,7 +645,13 @@ describe('AI 评阅', () => {
port: 0,
async fetch(req) {
requests.push(await req.json())
const content = JSON.stringify({ scores: [{ item: '窗体界面', score: 8.6, reason: '布局整齐' }, { item: '注释规范', score: 9, reason: '注释充分' }], comment: '界面整齐,继续加油。' })
const content = JSON.stringify({
scores: [
{ item: '窗体界面', score: 8.6, reason: '布局整齐' },
{ item: '注释规范', score: 9, reason: '注释充分' },
],
comment: '界面整齐,继续加油。',
})
return Response.json({ choices: [{ message: { content } }] })
},
})
@@ -599,9 +666,15 @@ describe('AI 评阅', () => {
})
test('分数限制在 0 到满分并取整;评分项个数不对时报错', () => {
const manual = [{ title: 'A', points: 10 }, { title: 'B', points: 5 }]
const manual = [
{ title: 'A', points: 10 },
{ title: 'B', points: 5 },
]
expect(normalizeAiResult({ scores: [{ score: 8.6, reason: 'x' }, { score: -1 }], comment: ' 好 ' }, manual)).toEqual({
scores: [{ score: 9, reason: 'x' }, { score: 0, reason: '' }],
scores: [
{ score: 9, reason: 'x' },
{ score: 0, reason: '' },
],
comment: '好',
})
expect(() => normalizeAiResult({ scores: [{ score: 1 }] }, manual)).toThrow('评分项个数不对')
@@ -611,7 +684,15 @@ describe('AI 评阅', () => {
const t = await teacher()
const classId = await classWithStudents(t, [{ username: 's1', name: '甲同学', password: 'p1' }])
const files = (await loadSample(SAMPLE))!
const { body } = await t.post('/api/teacher/assignments', { classId, sampleId: SAMPLE, title: '报警灯', startMode: 'ui', template: files, scoring, deadline: null })
const { body } = await t.post('/api/teacher/assignments', {
classId,
sampleId: SAMPLE,
title: '报警灯',
startMode: 'ui',
template: files,
scoring,
deadline: null,
})
const assignmentId = body.id as number
const student = new Browser()
await student.login('s1', 'p1')
@@ -622,7 +703,14 @@ describe('AI 评阅', () => {
const userId = (await student.get('/api/me')).body.user.id
const detail = (await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body
expect(detail.aiReview).toMatchObject({ status: 'done', scores: [{ score: 9, reason: '布局整齐' }, { score: 5, reason: '注释充分' }], comment: '界面整齐,继续加油。' })
expect(detail.aiReview).toMatchObject({
status: 'done',
scores: [
{ score: 9, reason: '布局整齐' },
{ score: 5, reason: '注释充分' },
],
comment: '界面整齐,继续加油。',
})
expect(detail.screenshots).toEqual([{ form: 'Form1', png }])
expect((await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body[0]).toMatchObject({ aiStatus: 'done', aiManual: 14, review: null })
expect((await t.get(`/api/teacher/classes/${classId}/gradebook`)).body.students[0].scores[assignmentId]).toMatchObject({ manual: null, aiManual: 14 })
+17 -14
View File
@@ -195,9 +195,7 @@ try {
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 }),
)
await phase('边写边检查', students, (s) => s.client.request('POST', '/api/language', { type: 'diagnostics', session: 'tab', files: s.files }))
// 老师说"运行看看":全班 SPREAD 内先后按 F5
await phase('运行到窗体出现', students, async (s) => {
@@ -209,17 +207,22 @@ try {
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
})
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) => {
+29 -9
View File
@@ -37,11 +37,15 @@ 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(23))
for (const id of ids) {
test(id, async () => {
const result = await grade(await prepare(id, id), await checksOf(id), id)
expect(result.compiled).toBe(true)
expect(result.results.filter((r) => !r.passed)).toEqual([])
}, 60000)
test(
id,
async () => {
const result = await grade(await prepare(id, id), await checksOf(id), id)
expect(result.compiled).toBe(true)
expect(result.results.filter((r) => !r.passed)).toEqual([])
},
60000,
)
}
})
@@ -66,7 +70,11 @@ describe('改坏的代码', () => {
test('判断写反:入侵时显示"正常"', async () => {
const id = 'p4-t1-红外对射'
const dir = await prepare(id, 'reversed', { file: 'Form1.cs', from: 'if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)', to: 'if (!DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)' })
const dir = await prepare(id, 'reversed', {
file: 'Form1.cs',
from: 'if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)',
to: 'if (!DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)',
})
const result = await grade(dir, await checksOf(id), id)
const realtime = result.results.find((r) => r.title === '能实时显示入侵数据')!
expect(realtime.passed).toBe(false)
@@ -94,7 +102,11 @@ describe('改坏的代码', () => {
test('数据库:照书上 INSERT 不写列名,温度和光照存反了', async () => {
const id = 'p6-t2-存储环境数据'
const dir = await prepare(id, 'insert-order', { file: 'Form1.cs', from: 'insert into CampusEnvironment (temperature, humidity, light, addtime) values', to: 'insert into CampusEnvironment values' })
const dir = await prepare(id, 'insert-order', {
file: 'Form1.cs',
from: 'insert into CampusEnvironment (temperature, humidity, light, addtime) values',
to: 'insert into CampusEnvironment values',
})
const result = await grade(dir, await checksOf(id), id)
const save = result.results.find((r) => r.title === '能把数据保存到数据库,并显示在表格中')!
expect(save.passed).toBe(false)
@@ -157,7 +169,11 @@ describe('改坏的代码', () => {
test('管理平台:开灯时换错了背景图片', async () => {
const id = 'p8-t3-环境采集与分析'
const dir = await prepare(id, 'lamp-image', { file: 'Environment.cs', from: 'panel_bg.BackgroundImage = Properties.Resources.day_01;', to: 'panel_bg.BackgroundImage = Properties.Resources.day_02;' })
const dir = await prepare(id, 'lamp-image', {
file: 'Environment.cs',
from: 'panel_bg.BackgroundImage = Properties.Resources.day_01;',
to: 'panel_bg.BackgroundImage = Properties.Resources.day_02;',
})
const result = await grade(dir, await checksOf(id), id)
const lamp = result.results.find((r) => r.title === '路灯能手动控制')!
expect(lamp).toMatchObject({ passed: false, message: '期望 panel_bg 背景换成亮灯的场景(day_01),实际是别的图片' })
@@ -165,7 +181,11 @@ describe('改坏的代码', () => {
test('报警灯接错了 DO 口', async () => {
const id = 'p4-t3-报警灯'
const dir = await prepare(id, 'relay', { file: 'Form1.cs', from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);', to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);' })
const dir = await prepare(id, 'relay', {
file: 'Form1.cs',
from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);',
to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);',
})
const result = await grade(dir, await checksOf(id), id)
expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' })
}, 60000)
+70 -43
View File
@@ -176,7 +176,13 @@ test('编译错误:书上常见的 True 大写给出中文提示', async () =>
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;'))
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')
@@ -193,9 +199,9 @@ test('项目2 任务2:温度读数来自实训台,滑块变化后再次获
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,
)
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' })
@@ -279,19 +285,23 @@ test('项目1 任务1:多窗体——打开增加用户窗体、添加用户
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(
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
.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 })
@@ -483,8 +493,17 @@ test('旧版线程 API:Suspend / Resume / Abort、线程自己挂起、休眠
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',
'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)
}
@@ -677,15 +696,17 @@ test('TrackBar:拖动时先 ValueChanged 再 Scroll,超出范围的值截到
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('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',
`
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"));
@@ -695,19 +716,21 @@ test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别
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)
)
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(
@@ -768,14 +791,18 @@ using System.Runtime.InteropServices;
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.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,
)
+4 -1
View File
@@ -13,7 +13,10 @@ function tableNames(task: string): string[] {
previous = []
continue
}
const cells = line.trim().replace(/^\||\|$/g, '').split('|')
const cells = line
.trim()
.replace(/^\||\|$/g, '')
.split('|')
// 表头是分隔行(|---|)上面那一行;数据行里出现"名称"两个字不算表头
if (cells.every((c) => /^\s*:?-+:?\s*$/.test(c))) column = previous.findIndex((c) => c.includes('名称'))
else if (column >= 0) names.push(...[...(cells[column] ?? '').matchAll(/`([^`]+)`/g)].map((m) => m[1]))