布置作业、提交与自动评分

- 作业:老师选实验、起点(界面已做好 / 空白工程)、截止时间、每项分值(自动评分项 + 老师评分项);
  起点工程由设计器引擎从参考答案生成(去掉事件绑定和事件处理方法),布置时保存起点和评分规则快照
- 提交:后台排队评分(全站同时最多评 3 份、跑 6 个检查项),重启后继续评;评分中不能再交,截止后不能交
- 学生:"我的作业"列表;实验页的作业模式(作业名、截止时间、提交、得分),评分结果弹出逐项显示
- 老师:班级管理页加"作业"标签页,布置、修改、删除作业,查看全班提交情况;改分值后重新算分
- 串口列表为空时提示在 Load 或 Click 事件里填充
- 测试:作业接口(可见范围、排队评分、部分得分、改分值、截止、删除);10 个示例两种起点都能编译

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 19:02:22 +08:00
1 parent 37fc747b49
commit 4e6b0d5cb2
23 files changed
+1439 -50

No files matched your search

+116 -4
View File
@@ -3,7 +3,21 @@ import type { Database } from 'bun:sqlite'
import { Hono, type Context } from 'hono'
import { bearerToken, login, logout, refresh, tokenUser, tooManyFailures, type Tokens, type User } from './auth'
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
import { listSamples, loadSample } from './samples'
import type { Scoring, StartMode } from '@iot/shared'
import {
assignmentTemplate,
classSubmissions,
createAssignment,
createSubmission,
deleteAssignment,
getAssignment,
latestSubmission,
listAssignments,
updateAssignment,
} from './assignments'
import { parseChecks } from './grading/checks'
import type { GradingQueue } from './grading/queue'
import { listSamples, loadChecks, loadSample } from './samples'
import { serveStatic } from './static'
import { deleteWork, getWork, saveWork, validFiles } from './works'
@@ -22,7 +36,7 @@ function clientIp(c: AppContext) {
/** 读取 JSON 请求体,格式不对时返回空对象 */
const body = <T>(c: AppContext) => c.req.json<Partial<T>>().catch(() => ({}) as Partial<T>)
export function createApp(db: Database) {
export function createApp(db: Database, gradingQueue: GradingQueue) {
const app = new Hono<{ Bindings: Env; Variables: { user: User } }>()
app.post('/api/login', async (c) => {
@@ -82,14 +96,62 @@ export function createApp(db: Database) {
return c.json({})
})
// ---- 作业(学生) ----
/** 当前用户能不能看这个作业:老师都能看,学生只能看自己班的 */
const id = (c: AppContext) => Number(c.req.param('id'))
const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result))
const visibleAssignment = (c: AppContext) => {
const assignment = getAssignment(db, id(c))
const user = c.get('user')
return assignment && (user.role === 'teacher' || assignment.classId === user.classId) ? assignment : null
}
// 自己班的作业,带上自己最近一次提交的状态
app.get('/api/assignments', (c) => {
const user = c.get('user')
if (user.classId === null) return c.json([])
return c.json(
listAssignments(db, user.classId).map((a) => {
const latest = latestSubmission(db, a.id, user.id)
return { ...a, latest: latest && { ...latest, result: null } }
}),
)
})
app.get('/api/assignments/:id', (c) => {
const assignment = visibleAssignment(c)
if (!assignment) return c.json({ message: '作业不存在' }, 404)
return c.json({ assignment, template: assignmentTemplate(db, assignment.id), latest: latestSubmission(db, assignment.id, c.get('user').id) })
})
app.post('/api/assignments/:id/submit', async (c) => {
const assignment = visibleAssignment(c)
if (!assignment) return c.json({ message: '作业不存在' }, 404)
const user = c.get('user')
if (user.role === 'student' && assignment.deadline !== null && Date.now() > assignment.deadline) {
return c.json({ message: '已经过了截止时间,不能再提交' }, 400)
}
const { files } = await body<{ files: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
const submissionId = createSubmission(db, assignment.id, user.id, files)
if (submissionId === null) return c.json({ message: '上一次提交还在评分,请等评分结束再提交' }, 409)
gradingQueue.enqueue(submissionId)
return c.json(latestSubmission(db, assignment.id, user.id))
})
app.get('/api/assignments/:id/submission', (c) => {
const assignment = visibleAssignment(c)
if (!assignment) return c.json({ message: '作业不存在' }, 404)
return c.json(latestSubmission(db, assignment.id, c.get('user').id))
})
// ---- 老师 ----
app.use('/api/teacher/*', async (c, next) => {
if (c.get('user').role !== 'teacher') return c.json({ message: '只有老师能使用' }, 403)
await next()
})
const id = (c: AppContext) => Number(c.req.param('id'))
const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result))
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
@@ -127,6 +189,56 @@ export function createApp(db: Database) {
return c.json({})
})
// 示例的自动评分项名称(布置作业时设置分值用)
app.get('/api/teacher/samples/:id/checks', async (c) => {
const checks = await loadChecks(c.req.param('id'))
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 404)
return c.json(parseChecks(checks).map((check) => check.title))
})
// 班级的作业,带上提交人数
app.get('/api/teacher/classes/:id/assignments', (c) =>
c.json(
listAssignments(db, id(c)).map((a) => {
const students = classSubmissions(db, a.id)
return { ...a, studentCount: students.length, submittedCount: students.filter((s) => s.status).length }
}),
),
)
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 }>(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)
if (a.startMode !== 'blank' && a.startMode !== 'ui') return c.json({ message: '起点只能是空白工程或界面已做好' }, 400)
if (!validFiles(a.template)) return c.json({ message: '起点工程格式不对' }, 400)
const result = createAssignment(db, {
classId: a.classId!,
sampleId: a.sampleId!,
title: String(a.title ?? ''),
startMode: a.startMode,
template: a.template,
checks,
scoring: a.scoring as Scoring,
deadline: typeof a.deadline === 'number' ? a.deadline : null,
})
return 'error' in result ? c.json({ message: result.error }, 400) : c.json(result)
})
app.patch('/api/teacher/assignments/:id', async (c) => {
const a = await body<{ title: string; deadline: number | null; scoring: Scoring }>(c)
return reply(c, updateAssignment(db, id(c), { title: String(a.title ?? ''), deadline: typeof a.deadline === 'number' ? a.deadline : null, scoring: a.scoring as Scoring }))
})
app.delete('/api/teacher/assignments/:id', (c) => {
deleteAssignment(db, id(c))
return c.json({})
})
// 全班每个学生最近一次提交的情况
app.get('/api/teacher/assignments/:id/submissions', (c) => c.json(classSubmissions(db, id(c))))
app.all('/api/*', (c) => c.json({ message: '接口不存在' }, 404))
// 打包好的网页(部署时);开发时网页由 Vite 提供
+203
View File
@@ -0,0 +1,203 @@
// 作业:老师把一个实验布置给一个班,学生提交后自动评分。
// 布置时保存起点工程和评分规则的快照,以后改示例不影响已布置的作业。
import type { Database } from 'bun:sqlite'
import type { Assignment, GradeResult, Scoring, SourceFile, StartMode, Submission } from '@iot/shared'
import { parseChecks } from './grading/checks'
interface AssignmentRow {
id: number
class_id: number
class_name: string
sample_id: string
title: string
start_mode: StartMode
checks: string
scoring: string
deadline: number | null
created_at: number
}
const SELECT_ASSIGNMENT = `
SELECT a.id, a.class_id, c.name AS class_name, a.sample_id, a.title, a.start_mode, a.checks, a.scoring, a.deadline, a.created_at
FROM assignments a JOIN classes c ON c.id = a.class_id`
function toAssignment(row: AssignmentRow): Assignment {
return {
id: row.id,
classId: row.class_id,
className: row.class_name,
sampleId: row.sample_id,
title: row.title,
startMode: row.start_mode,
deadline: row.deadline,
createdAt: row.created_at,
checkTitles: parseChecks(row.checks).map((c) => c.title),
scoring: JSON.parse(row.scoring) as Scoring,
}
}
export function listAssignments(db: Database, classId: number): Assignment[] {
return db.query<AssignmentRow, [number]>(`${SELECT_ASSIGNMENT} WHERE a.class_id = ? ORDER BY a.created_at DESC`).all(classId).map(toAssignment)
}
export function getAssignment(db: Database, id: number): Assignment | null {
const row = db.query<AssignmentRow, [number]>(`${SELECT_ASSIGNMENT} WHERE a.id = ?`).get(id)
return row ? toAssignment(row) : null
}
/** 评分规则的快照(评分时用) */
export function assignmentChecks(db: Database, id: number) {
const row = db.query<{ checks: string }, [number]>('SELECT checks FROM assignments WHERE id = ?').get(id)
return row ? parseChecks(row.checks) : null
}
export function assignmentTemplate(db: Database, id: number): SourceFile[] | null {
const row = db.query<{ template: string }, [number]>('SELECT template FROM assignments WHERE id = ?').get(id)
return row ? (JSON.parse(row.template) as SourceFile[]) : null
}
/** 检查分值:自动评分项个数要和规则一致,分值是不小于 0 的数,老师评分项要有名称 */
function scoringError(scoring: Scoring, checkCount: number): string | null {
if (!Array.isArray(scoring?.auto) || scoring.auto.length !== checkCount) return `自动评分项应有 ${checkCount} 项`
if (!Array.isArray(scoring.manual)) return '老师评分项格式不对'
const valid = (p: unknown) => typeof p === 'number' && Number.isFinite(p) && p >= 0
if (!scoring.auto.every(valid) || !scoring.manual.every((m) => valid(m?.points))) return '分值要填不小于 0 的数'
if (scoring.manual.some((m) => typeof m.title !== 'string' || !m.title.trim())) return '老师评分项要填名称'
return null
}
export interface NewAssignment {
classId: number
sampleId: string
title: string
startMode: StartMode
template: SourceFile[]
/** checks.yaml 的原文 */
checks: string
scoring: Scoring
deadline: number | null
}
export function createAssignment(db: Database, a: NewAssignment): { id: number } | { error: string } {
if (!a.title.trim()) return { error: '作业名称不能为空' }
const error = scoringError(a.scoring, parseChecks(a.checks).length)
if (error) return { error }
const result = db
.query(
`INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now())
return { id: Number(result.lastInsertRowid) }
}
/** 修改名称、截止时间、分值(起点工程和评分规则布置后不能改) */
export function updateAssignment(db: Database, id: number, changes: { title: string; deadline: number | null; scoring: Scoring }): { error?: string } {
const checks = assignmentChecks(db, id)
if (!checks) return { error: '作业不存在' }
if (!changes.title.trim()) return { error: '作业名称不能为空' }
const error = scoringError(changes.scoring, checks.length)
if (error) return { error }
db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ? WHERE id = ?').run(
changes.title.trim(),
changes.deadline,
JSON.stringify(changes.scoring),
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 {}
}
export function deleteAssignment(db: Database, id: number) {
db.query('DELETE FROM assignments WHERE id = ?').run(id)
}
/** 通过的自动评分项的分值之和 */
export function autoScore(result: GradeResult, scoring: Scoring) {
return result.results.reduce((sum, r, i) => sum + (r.passed ? (scoring.auto[i] ?? 0) : 0), 0)
}
// ---------- 提交 ----------
interface SubmissionRow {
id: number
status: Submission['status']
result: string | null
auto_score: number | null
created_at: number
}
function toSubmission(db: Database, row: SubmissionRow): Submission {
const submission: Submission = {
id: row.id,
status: row.status,
result: row.result ? (JSON.parse(row.result) as GradeResult) : null,
autoScore: row.auto_score,
createdAt: row.created_at,
}
if (row.status === 'pending') {
submission.queueAhead = db.query<{ n: number }, [number]>(`SELECT COUNT(*) AS n FROM submissions WHERE status = 'pending' AND id < ?`).get(row.id)!.n
}
return submission
}
/** 某个学生在某个作业上最近一次提交 */
export function latestSubmission(db: Database, assignmentId: number, userId: number): Submission | null {
const row = db
.query<SubmissionRow, [number, number]>(
'SELECT id, status, result, auto_score, created_at FROM submissions WHERE assignment_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1',
)
.get(assignmentId, userId)
return row ? toSubmission(db, row) : null
}
/** 新建一份等待评分的提交;同一个人上一份还在评分时返回 null */
export function createSubmission(db: Database, assignmentId: number, userId: number, files: SourceFile[]): number | null {
if (latestSubmission(db, assignmentId, userId)?.status === 'pending') return null
const result = db
.query(`INSERT INTO submissions (assignment_id, user_id, files, status, created_at) VALUES (?, ?, ?, 'pending', ?)`)
.run(assignmentId, userId, JSON.stringify(files), Date.now())
return Number(result.lastInsertRowid)
}
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)
}
export function submissionForGrading(db: Database, id: number) {
const row = db.query<{ assignment_id: number; files: string }, [number]>('SELECT assignment_id, files FROM submissions WHERE id = ?').get(id)
return row ? { assignmentId: row.assignment_id, files: JSON.parse(row.files) as SourceFile[] } : null
}
export function saveGrade(db: Database, id: number, result: GradeResult | null) {
const row = db.query<{ scoring: string }, [number]>('SELECT a.scoring FROM submissions s JOIN assignments a ON a.id = s.assignment_id WHERE s.id = ?').get(id)
if (!row) return
if (!result) {
db.query(`UPDATE submissions SET status = 'error', graded_at = ? WHERE id = ?`).run(Date.now(), id)
return
}
db.query(`UPDATE submissions SET status = 'done', result = ?, auto_score = ?, graded_at = ? WHERE id = ?`).run(
JSON.stringify(result),
autoScore(result, JSON.parse(row.scoring) as Scoring),
Date.now(),
id,
)
}
/** 每个学生最近一次提交的状态(老师看提交情况) */
export function classSubmissions(db: Database, assignmentId: number) {
return db
.query<{ user_id: number; username: string; name: string; 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.status, s.auto_score, s.created_at,
(SELECT COUNT(*) FROM submissions x WHERE x.assignment_id = ?1 AND x.user_id = u.id) AS count
FROM users u
JOIN assignments a ON a.id = ?1 AND u.class_id = a.class_id AND u.role = 'student'
LEFT JOIN submissions s ON s.id = (SELECT MAX(id) FROM submissions WHERE assignment_id = ?1 AND user_id = u.id)
ORDER BY u.username`,
)
.all(assignmentId)
.map((r) => ({ userId: r.user_id, username: r.username, name: r.name, status: r.status, autoScore: r.auto_score, submittedAt: r.created_at, count: r.count }))
}
+32 -1
View File
@@ -1,4 +1,4 @@
// SQLite 数据库:账号、班级、学生作品、平台设置(如 JWT 签名密钥)。
// SQLite 数据库:账号、班级、学生作品、作业与提交、平台设置(如 JWT 签名密钥)。
// 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。
import { Database } from 'bun:sqlite'
import { mkdirSync } from 'node:fs'
@@ -54,6 +54,37 @@ const MIGRATIONS = [
value TEXT NOT NULL
);
`,
// 作业:布置时保存起点工程和评分规则的快照,以后改示例不影响已布置的作业。
// scoring 是 JSON:{ auto: 每个自动评分项的分值(按规则顺序), manual: [{ title, points }] 老师评分项 }
`
CREATE TABLE assignments (
id INTEGER PRIMARY KEY,
class_id INTEGER NOT NULL REFERENCES classes (id) ON DELETE CASCADE,
sample_id TEXT NOT NULL,
title TEXT NOT NULL,
start_mode TEXT NOT NULL CHECK (start_mode IN ('blank', 'ui')),
template TEXT NOT NULL,
checks TEXT NOT NULL,
scoring TEXT NOT NULL,
deadline INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX assignments_class ON assignments (class_id);
-- 提交:status 为 pending(排队评分中)、done(已评分)或 error(评分出错);result 是自动评分结果 JSON
CREATE TABLE submissions (
id INTEGER PRIMARY KEY,
assignment_id INTEGER NOT NULL REFERENCES assignments (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
files TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'done', 'error')),
result TEXT,
auto_score REAL,
created_at INTEGER NOT NULL,
graded_at INTEGER
);
CREATE INDEX submissions_assignment_user ON submissions (assignment_id, user_id, id);
CREATE INDEX submissions_status ON submissions (status);
`,
]
/** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */
+5 -3
View File
@@ -1,11 +1,11 @@
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
// 服务器只有 2 核,全站同时最多跑 GRADE_CONCURRENCY 个检查,其余排队。
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
import { compileService } from '../compile-service'
import type { Check, Expectation, Step } from './checks'
import { ProgramDriver } from './driver'
const GRADE_CONCURRENCY = 2
const GRADE_CONCURRENCY = 6
/** 程序启动后多久内应该显示出窗体 */
const START_TIMEOUT = 10_000
@@ -92,7 +92,9 @@ async function runStep(driver: ProgramDriver, step: Step) {
const items = ((node.props.Items as unknown[] | undefined) ?? []).map(String)
const index = items.indexOf(step.item)
if (index < 0) {
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})`)
// 串口列表是空的:最常见的原因是没有填充
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 })
await driver.settle()
+70
View File
@@ -0,0 +1,70 @@
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。
// 服务器重启前没评完的提交,启动时继续评。
import type { Database } from 'bun:sqlite'
import { mkdir, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { assignmentChecks, pendingSubmissionIds, saveGrade, submissionForGrading } from '../assignments'
import { config } from '../config'
import { safeJoin } from '../run-session'
import { grade } from './grader'
const MAX_ACTIVE = 3
export function createGradingQueue(db: Database) {
const queue: number[] = []
let active = 0
let idleWaiters: (() => void)[] = []
async function gradeOne(id: number) {
const submission = submissionForGrading(db, id)
const checks = submission && assignmentChecks(db, submission.assignmentId)
if (!submission || !checks) return
const dir = join(config.runsDir, `grade-${id}`)
try {
await rm(dir, { recursive: true, force: true })
for (const file of submission.files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
saveGrade(db, id, await grade(dir, checks))
} catch (error) {
console.error(`评分出错(提交 ${id}):`, error)
saveGrade(db, id, null)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
function pump() {
while (active < MAX_ACTIVE && queue.length > 0) {
const id = queue.shift()!
active++
void gradeOne(id).finally(() => {
active--
pump()
})
}
if (active === 0 && queue.length === 0) {
for (const resolve of idleWaiters) resolve()
idleWaiters = []
}
}
for (const id of pendingSubmissionIds(db)) queue.push(id)
pump()
return {
enqueue(id: number) {
queue.push(id)
pump()
},
/** 等队列全部评完(测试用) */
idle(): Promise<void> {
if (active === 0 && queue.length === 0) return Promise.resolve()
return new Promise((resolve) => idleWaiters.push(resolve))
},
}
}
export type GradingQueue = ReturnType<typeof createGradingQueue>
+2 -1
View File
@@ -4,6 +4,7 @@ import { ensureTeacher, tokenUser } from './auth'
import { compileService } from './compile-service'
import { config } from './config'
import { openDatabase } from './db'
import { createGradingQueue } from './grading/queue'
import { cleanupStaleRuns, RunSession } from './run-session'
import { checkSandbox, sandboxError } from './sandbox'
@@ -13,7 +14,7 @@ if (sandboxError) console.error(sandboxError)
const db = openDatabase()
ensureTeacher(db)
const app = createApp(db)
const app = createApp(db, createGradingQueue(db))
interface SocketData {
session?: RunSession
+1 -1
View File
@@ -170,7 +170,7 @@ export class RunSession {
}
/** 拼接路径并拒绝跳出工程目录的文件名 */
function safeJoin(base: string, path: string) {
export function safeJoin(base: string, path: string) {
const target = resolve(base, normalize(path))
if (!target.startsWith(`${base}/`)) throw new Error(`非法文件路径:${path}`)
return target
+8
View File
@@ -32,3 +32,11 @@ export async function loadSample(id: string): Promise<SourceFile[] | null> {
}
return files.length > 0 ? files.sort((a, b) => a.path.localeCompare(b.path)) : null
}
/** 示例的评分规则(checks.yaml 原文);没有规则返回 null */
export async function loadChecks(id: string): Promise<string | null> {
const path = resolve(SAMPLES_DIR, id, 'checks.yaml')
if (!path.startsWith(`${SAMPLES_DIR}/`)) return null
const file = Bun.file(path)
return (await file.exists()) ? file.text() : null
}
+102 -1
View File
@@ -4,13 +4,17 @@ import { createApp } from '../src/app'
import { ensureTeacher } from '../src/auth'
import { config } from '../src/config'
import { openDatabase } from '../src/db'
import { createGradingQueue, type GradingQueue } from '../src/grading/queue'
import { loadSample } from '../src/samples'
let app: ReturnType<typeof createApp>
let queue: GradingQueue
beforeEach(() => {
const db = openDatabase(':memory:')
ensureTeacher(db)
app = createApp(db)
queue = createGradingQueue(db)
app = createApp(db, queue)
})
/** 一个浏览器:记住访问令牌和刷新令牌 */
@@ -238,3 +242,100 @@ describe('令牌', () => {
expect((await t.get('/api/me')).status).toBe(401)
})
})
describe('作业', () => {
const SAMPLE = 'p4-t3-报警灯'
const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }] }
/** 老师建班、导入一个学生、布置报警灯作业;返回老师、学生和作业 id */
async function setup(deadline: number | null = null) {
const t = await teacher()
const classId = await classWithStudents(t, [{ username: 's1', name: '甲', password: 'p1' }])
const template = (await loadSample(SAMPLE))!
const created = await t.post('/api/teacher/assignments', { classId, sampleId: SAMPLE, title: '控制报警灯', startMode: 'ui', template, scoring, deadline })
expect(created.status).toBe(200)
const student = new Browser()
await student.login('s1', 'p1')
return { t, student, classId, assignmentId: created.body.id as number, files: template }
}
test('分值项数要和评分规则一致', async () => {
const t = await teacher()
const classId = await classWithStudents(t, [])
const result = await t.post('/api/teacher/assignments', {
classId,
sampleId: SAMPLE,
title: '控制报警灯',
startMode: 'ui',
template: [],
scoring: { auto: [10], manual: [] },
deadline: null,
})
expect(result.status).toBe(400)
expect(result.body.message).toBe('自动评分项应有 2 项')
expect((await t.get(`/api/teacher/samples/${encodeURIComponent(SAMPLE)}/checks`)).body).toEqual(['能正确打开报警灯', '能正确关闭报警灯'])
})
test('学生能看到自己班的作业和起点工程,别的班看不到', async () => {
const { t, assignmentId } = await setup()
const s1 = new Browser()
await s1.login('s1', 'p1')
const list = (await s1.get('/api/assignments')).body
expect(list).toHaveLength(1)
expect(list[0]).toMatchObject({ title: '控制报警灯', checkTitles: ['能正确打开报警灯', '能正确关闭报警灯'], latest: null })
expect((await s1.get(`/api/assignments/${assignmentId}`)).body.template.length).toBeGreaterThan(0)
const { body: other } = await t.post('/api/teacher/classes', { name: '另一个班' })
await t.post(`/api/teacher/classes/${other.id}/import`, { rows: [{ username: 's2', name: '乙', password: 'p2' }] })
const s2 = new Browser()
await s2.login('s2', 'p2')
expect((await s2.get('/api/assignments')).body).toEqual([])
expect((await s2.get(`/api/assignments/${assignmentId}`)).status).toBe(404)
})
test('提交后排队评分,评完得到每项结果和分数;老师能看到提交情况', async () => {
const { t, student, assignmentId, files } = await setup()
const submitted = await student.post(`/api/assignments/${assignmentId}/submit`, { files })
expect(submitted.body.status).toBe('pending')
// 上一份还没评完时不能再交
expect((await student.post(`/api/assignments/${assignmentId}/submit`, { files })).status).toBe(409)
await queue.idle()
const graded = (await student.get(`/api/assignments/${assignmentId}/submission`)).body
expect(graded).toMatchObject({ status: 'done', autoScore: 20 })
expect(graded.result.results.map((r: any) => r.passed)).toEqual([true, true])
const submissions = (await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body
expect(submissions).toEqual([expect.objectContaining({ username: 's1', status: 'done', autoScore: 20, count: 1 })])
expect((await t.get(`/api/teacher/classes/${(await t.get('/api/teacher/classes')).body[0].id}/assignments`)).body[0]).toMatchObject({
studentCount: 1,
submittedCount: 1,
})
}, 60000)
test('只做对一部分时按通过的项得分;老师改分值后重新算分', async () => {
const { t, student, assignmentId, files } = await setup()
const broken = files.map((f) => (f.path === 'Form1.cs' ? { ...f, content: f.content.replace('AlarmOnOff("Alarm", false);', '') } : f))
await student.post(`/api/assignments/${assignmentId}/submit`, { files: broken })
await queue.idle()
const graded = (await student.get(`/api/assignments/${assignmentId}/submission`)).body
expect(graded.autoScore).toBe(10)
expect(graded.result.results[1]).toMatchObject({ title: '能正确关闭报警灯', passed: false })
await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '控制报警灯', deadline: null, scoring: { ...scoring, auto: [30, 20] } })
expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body.autoScore).toBe(30)
}, 60000)
test('过了截止时间不能提交', async () => {
const { student, assignmentId, files } = await setup(Date.now() - 1000)
const result = await student.post(`/api/assignments/${assignmentId}/submit`, { files })
expect(result.status).toBe(400)
expect(result.body.message).toBe('已经过了截止时间,不能再提交')
})
test('删除作业后学生看不到', async () => {
const { t, student, assignmentId } = await setup()
await t.delete(`/api/teacher/assignments/${assignmentId}`)
expect((await student.get('/api/assignments')).body).toEqual([])
})
})