用 bun fmt 格式化全部代码和文档
- 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:
1 parent
d4b8bf9241
commit
de277d3e9d
102 files changed
+1971
-1059
No files matched your search
+11
-7
@@ -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
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 ? ' ……' : ''}` : '没有数据'}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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]))
|
||||
|
||||
Reference in new issue
Block a user