项目 6 数据存储:仿冒 SqlClient(T-SQL 转写成 SQLite)、数据库页面、DataGridView、DateTimePicker
- 每个学生一套数据库,长期保存(data/databases/<用户编号>/库名.db);学生程序运行时挂进沙箱可读写 - Sim.Data:SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter; 连接字符串只按库名打开学生自己的库;ScriptDom 解析 T-SQL 后按白名单转写成 SQLite 语句, STRICT 表 + 元数据表还原 SQL Server 的列类型,报错编号和中文信息与 SQL Server 一致; INSERT 不写列名时跳过自增列,日期统一格式(书上 DateTime.ToString() 拼接的日期能正确比较) - 常驻 SQL 服务(Sim.Runner sql-server):数据库页面的查询、对象资源管理器,评分前按实验 database.sql 建库 - 数据库页面(代替 SSMS):对象资源管理器、查询窗口、编辑前 200 行、表设计器、恢复实验初始数据 - DataGridView(绑定 DataView/DataTable/DataSet,单元格修改写回 DataTable)、DateTimePicker;设计器工具箱加入 - 评分:每个检查项一份新建的实验库;新增点 / 修改表格单元格、选日期、表格里有 / 没有某行的检查 - 示例:项目 6 任务 2、3;时间格式改成和中文 Windows 一致(9:05:03);异常堆栈不暴露服务器路径 - 测试:数据库端到端、接口(每人只看到自己的库、恢复实验数据)、评分出错情况、T-SQL 生成 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b088240da
commit
670abe5921
72 files changed
+7253
-51
No files matched your search
@@ -20,7 +20,10 @@ import { parseChecks } from './grading/checks'
|
||||
import { languageService } from './language-service'
|
||||
import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews'
|
||||
import type { GradingQueue } from './grading/queue'
|
||||
import { config } from './config'
|
||||
import { restoreExperimentDatabases, userDatabaseDir } from './databases'
|
||||
import { listSamples, loadChecks, loadSample } from './samples'
|
||||
import { SqlError, sqlService } from './sql-service'
|
||||
import { serveStatic } from './static'
|
||||
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
|
||||
|
||||
@@ -110,6 +113,34 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
return c.json({})
|
||||
})
|
||||
|
||||
// ---- 数据库页面(代替 SSMS):每个用户只能访问自己的数据库 ----
|
||||
app.get('/api/db', async (c) => c.json(await sqlService.request({ type: 'explorer', dir: await userDatabaseDir(c.get('user').id) })))
|
||||
|
||||
/** 执行 T-SQL;SQL 报错时返回 200 和 error(和 SSMS 的消息栏一样显示,不是请求失败) */
|
||||
app.post('/api/db/query', async (c) => {
|
||||
const { database, sql } = await body<{ database: string; sql: string }>(c)
|
||||
if (typeof sql !== 'string' || sql.length > 100_000 || typeof database !== 'string' || database.length > 64) return c.json({ message: '请求格式不对' }, 400)
|
||||
try {
|
||||
return c.json(await sqlService.request({ type: 'exec', dir: await userDatabaseDir(c.get('user').id), database, sql }))
|
||||
} catch (error) {
|
||||
if (error instanceof SqlError) return c.json({ type: 'error', number: error.number, message: error.message, line: error.line })
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
/** 有预置数据库的实验(数据库页面"恢复实验初始数据"的选项) */
|
||||
app.get('/api/db/experiments', async (c) => {
|
||||
const samples = await listSamples()
|
||||
const withDb = await Promise.all(samples.map(async (s) => ((await Bun.file(`${config.samplesDir}/${s.id}/database.sql`).exists()) ? s : null)))
|
||||
return c.json(withDb.filter((s) => s !== null))
|
||||
})
|
||||
|
||||
app.post('/api/db/restore', async (c) => {
|
||||
const { sampleId } = await body<{ sampleId: string }>(c)
|
||||
if (typeof sampleId !== 'string' || !(await listSamples()).some((s) => s.id === sampleId)) return c.json({ message: '没有这个实验' }, 400)
|
||||
return c.json({ databases: await restoreExperimentDatabases(c.get('user').id, sampleId) })
|
||||
})
|
||||
|
||||
// ---- 作业(学生) ----
|
||||
/** 当前用户能不能看这个作业:老师都能看,学生只能看自己班的 */
|
||||
const id = (c: AppContext) => Number(c.req.param('id'))
|
||||
|
||||
@@ -168,8 +168,12 @@ export function pendingSubmissionIds(db: Database): number[] {
|
||||
}
|
||||
|
||||
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
|
||||
const row = db
|
||||
.query<{ assignment_id: number; files: string; sample_id: string }, [number]>(
|
||||
'SELECT s.assignment_id, s.files, a.sample_id FROM submissions s JOIN assignments a ON a.id = s.assignment_id WHERE s.id = ?',
|
||||
)
|
||||
.get(id)
|
||||
return row ? { assignmentId: row.assignment_id, sampleId: row.sample_id, files: JSON.parse(row.files) as SourceFile[] } : null
|
||||
}
|
||||
|
||||
export function saveGrade(db: Database, id: number, result: GradeResult | null) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 学生的数据库:每个用户一个目录(data/databases/<用户编号>),每个库一个 SQLite 文件,长期保存,
|
||||
// 就像学生自己电脑上的 SQL Server。学生程序运行时挂进沙箱可读写;数据库页面通过 SQL 服务访问。
|
||||
// 评分时不用学生自己的库,而是按实验的 database.sql 新建一套干净的库。
|
||||
import { cp, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { config } from './config'
|
||||
import { sqlService } from './sql-service'
|
||||
|
||||
/** 用户的数据库目录(不存在时创建) */
|
||||
export async function userDatabaseDir(userId: number) {
|
||||
const dir = resolve(config.dataDir, 'databases', String(userId))
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 实验预置的数据库脚本(samples/<实验>/database.sql),没有时返回 undefined */
|
||||
export async function experimentScript(sampleId: string): Promise<string | undefined> {
|
||||
const file = Bun.file(join(config.samplesDir, sampleId, 'database.sql'))
|
||||
return (await file.exists()) ? file.text() : undefined
|
||||
}
|
||||
|
||||
/** 按脚本建库:先清空目录再执行(脚本里 CREATE DATABASE、CREATE TABLE、INSERT 都可以写) */
|
||||
export async function runScript(dir: string, script: string) {
|
||||
await mkdir(dir, { recursive: true })
|
||||
await sqlService.request({ type: 'script', dir, sql: script })
|
||||
}
|
||||
|
||||
/**
|
||||
* 把学生某个实验用到的库恢复成实验的初始数据:只替换脚本里建的库,学生的其他库不动。
|
||||
* 返回恢复了哪些库。
|
||||
*/
|
||||
export async function restoreExperimentDatabases(userId: number, sampleId: string) {
|
||||
const script = await experimentScript(sampleId)
|
||||
if (!script) return []
|
||||
const temp = await mkdtemp(join(config.runsDir, 'db-'))
|
||||
try {
|
||||
await runScript(temp, script)
|
||||
const target = await userDatabaseDir(userId)
|
||||
const files = (await readdir(temp)).filter((f) => f.endsWith('.db') && f !== 'master.db')
|
||||
for (const file of files) {
|
||||
for (const suffix of ['', '-wal', '-shm']) await rm(join(target, file + suffix), { force: true })
|
||||
await cp(join(temp, file), join(target, file))
|
||||
}
|
||||
return files.map((f) => f.slice(0, -3))
|
||||
} finally {
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** 评分用:实验数据库的模板按实验缓存,每个检查项复制一份(互不影响) */
|
||||
const templates = new Map<string, Promise<string | undefined>>()
|
||||
|
||||
export async function gradingDatabaseDir(sampleId: string): Promise<string | undefined> {
|
||||
let template = templates.get(sampleId)
|
||||
if (!template) {
|
||||
template = (async () => {
|
||||
const script = await experimentScript(sampleId)
|
||||
if (script === undefined) return undefined
|
||||
const dir = resolve(config.runsDir, 'db-templates', sampleId)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
await runScript(dir, script)
|
||||
return dir
|
||||
})()
|
||||
templates.set(sampleId, template)
|
||||
template.catch(() => templates.delete(sampleId))
|
||||
}
|
||||
const source = await template
|
||||
const copy = await mkdtemp(join(config.runsDir, 'grade-db-'))
|
||||
if (source && (await stat(source).catch(() => null))) await cp(source, copy, { recursive: true })
|
||||
return copy
|
||||
}
|
||||
@@ -11,6 +11,12 @@ export type Step =
|
||||
| { kind: 'click'; control: string }
|
||||
| { kind: 'text'; control: string; value: string }
|
||||
| { kind: 'select'; control: string; item: string }
|
||||
/** 点数据表格的单元格;column 是列号或列标题 */
|
||||
| { kind: 'cell'; control: string; row: number; column: number | string }
|
||||
/** 在数据表格的单元格里输入新值 */
|
||||
| { kind: 'edit'; control: string; row: number; column: number | string; value: string }
|
||||
/** 在日期时间选择器里选日期(和时间):2024-01-15 或 2024-01-15 08:30:00 */
|
||||
| { kind: 'pick'; control: string; value: string }
|
||||
| { kind: 'board'; analog: Partial<Record<AnalogKey, number>>; digital: Partial<Record<DigitalKey, boolean>> }
|
||||
| { kind: 'wait'; ms: number }
|
||||
/** desc:给学生看的期望说明,如"显示当前时间",不写就按期望自动生成 */
|
||||
@@ -34,6 +40,8 @@ export type Expectation =
|
||||
| { kind: 'camera'; state: 'on' | 'off' | 'up' | 'down' | 'left' | 'right' }
|
||||
/** 程序用 Image.Save 保存了图片,路径包含这段文字(如 Camera/) */
|
||||
| { kind: 'savedImage'; path: string }
|
||||
/** 数据表格里有(present)/ 没有一行同时满足这些"列标题=值" */
|
||||
| { kind: 'row'; control: string; cells: Record<string, string>; present: boolean }
|
||||
| { kind: 'console'; text: string }
|
||||
|
||||
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
|
||||
@@ -81,6 +89,17 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
|
||||
return { kind: 'matches', control, pattern: new RegExp(source), source }
|
||||
}
|
||||
if ('unchanged' in raw) return { kind: 'unchanged', control, ms: duration(raw.unchanged, where) }
|
||||
if ('row' in raw || 'noRow' in raw) {
|
||||
const list = raw.row ?? raw.noRow
|
||||
if (!Array.isArray(list) || list.length === 0) throw new Error(`${where}:row 要写成 [列标题=值, ...]`)
|
||||
const cells: Record<string, string> = {}
|
||||
for (const item of list) {
|
||||
const [key, ...rest] = String(item).split('=')
|
||||
if (!key || rest.length === 0) throw new Error(`${where}:row 里的 ${item} 要写成 列标题=值`)
|
||||
cells[key.trim()] = rest.join('=').trim()
|
||||
}
|
||||
return { kind: 'row', control, cells, present: 'row' in raw }
|
||||
}
|
||||
if ('image' in raw) return { kind: 'image', control }
|
||||
for (const [key, prop] of Object.entries(PROP_KEYS)) {
|
||||
if (key in raw) return { kind: 'prop', control, prop, value: STRING_PROPS.has(prop) ? String(raw[key]) : bool(raw[key], where, key) }
|
||||
@@ -121,6 +140,18 @@ function parseStep(raw: unknown, where: string): Step {
|
||||
return { kind, control: str(obj.control, where, '控件名 control'), value: String(obj.value ?? '') }
|
||||
case 'select':
|
||||
return { kind, control: str(obj.control, where, '控件名 control'), item: str(obj.item, where, '要选的项 item') }
|
||||
case 'pick': {
|
||||
const value = str(obj.value, where, '日期 value')
|
||||
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(value)) throw new Error(`${where}:日期要写成 2024-01-15 或 2024-01-15 08:30:00`)
|
||||
return { kind, control: str(obj.control, where, '控件名 control'), value }
|
||||
}
|
||||
case 'cell':
|
||||
case 'edit': {
|
||||
const control = str(obj.control, where, '控件名 control')
|
||||
if (typeof obj.row !== 'number') throw new Error(`${where}:row 要写行号(从 0 开始)`)
|
||||
const column = typeof obj.column === 'number' ? obj.column : str(obj.column, where, '列号或列标题 column')
|
||||
return kind === 'cell' ? { kind, control, row: obj.row, column } : { kind, control, row: obj.row, column, value: String(obj.value ?? '') }
|
||||
}
|
||||
case 'board':
|
||||
return parseBoard(obj, where)
|
||||
case 'wait':
|
||||
|
||||
@@ -25,8 +25,8 @@ export class ProgramDriver {
|
||||
private listeners = new Set<() => void>()
|
||||
private lastMessageAt = Date.now()
|
||||
|
||||
start(projectDir: string) {
|
||||
const { cmd, env } = runCommand(projectDir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) })
|
||||
start(projectDir: string, dbDir?: string) {
|
||||
const { cmd, env } = runCommand(projectDir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) }, dbDir)
|
||||
const proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env })
|
||||
this.proc = proc
|
||||
void readLines(proc.stdout, (line) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
|
||||
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。
|
||||
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { compileService } from '../compile-service'
|
||||
import { gradingDatabaseDir } from '../databases'
|
||||
import type { Check, Expectation, Step } from './checks'
|
||||
import { ProgramDriver } from './driver'
|
||||
|
||||
@@ -30,8 +32,8 @@ async function withSlot<T>(task: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 给学生工程评分;dir 是已写好源文件的工程目录 */
|
||||
export async function grade(dir: string, checks: Check[]): Promise<GradeResult> {
|
||||
/** 给学生工程评分;dir 是已写好源文件的工程目录,sampleId 决定用哪个实验的预置数据库(每个检查项一份) */
|
||||
export async function grade(dir: string, checks: Check[], sampleId?: string): Promise<GradeResult> {
|
||||
const start = Date.now()
|
||||
const compiled = await compileService.compile(dir)
|
||||
if (!compiled.ok) {
|
||||
@@ -41,16 +43,17 @@ export async function grade(dir: string, checks: Check[]): Promise<GradeResult>
|
||||
ms: Date.now() - start,
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(checks.map((check) => withSlot(() => runCheck(dir, check))))
|
||||
const results = await Promise.all(checks.map((check) => withSlot(() => runCheck(dir, check, sampleId))))
|
||||
return { compiled: true, results, ms: Date.now() - start }
|
||||
}
|
||||
|
||||
class Failure extends Error {}
|
||||
|
||||
async function runCheck(dir: string, check: Check): Promise<CheckResult> {
|
||||
async function runCheck(dir: string, check: Check, sampleId?: string): Promise<CheckResult> {
|
||||
const driver = new ProgramDriver()
|
||||
const dbDir = sampleId ? await gradingDatabaseDir(sampleId) : undefined
|
||||
try {
|
||||
driver.start(dir)
|
||||
driver.start(dir, dbDir)
|
||||
if (!(await driver.waitFor(() => driver.forms.length > 0 || driver.exited, START_TIMEOUT)) || driver.forms.length === 0) {
|
||||
throw new Failure(driver.exceptions[0] ? `程序出错:${driver.exceptions[0]}` : '程序启动后没有显示窗体')
|
||||
}
|
||||
@@ -64,6 +67,7 @@ async function runCheck(dir: string, check: Check): Promise<CheckResult> {
|
||||
throw error
|
||||
} finally {
|
||||
driver.stop()
|
||||
if (dbDir) await rm(dbDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +107,28 @@ async function runStep(driver: ProgramDriver, step: Step) {
|
||||
await driver.settle()
|
||||
break
|
||||
}
|
||||
case 'cell':
|
||||
case 'edit': {
|
||||
const node = need(driver, step.control)
|
||||
if (node.type !== 'DataGridView') throw new Failure(`${step.control} 不是数据表格`)
|
||||
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('、') || '没有'})`)
|
||||
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()
|
||||
break
|
||||
}
|
||||
case 'pick': {
|
||||
const node = need(driver, step.control)
|
||||
if (node.type !== 'DateTimePicker') throw new Failure(`${step.control} 不是日期时间选择器`)
|
||||
// 只给日期时:时间取中午,查询"某天到某天"时不受时分影响
|
||||
const [date, time = '12:00:00'] = step.value.split(' ')
|
||||
driver.act(node, { action: 'pick', value: `${date}T${time.length === 5 ? `${time}:00` : time}` })
|
||||
await driver.settle()
|
||||
break
|
||||
}
|
||||
case 'board':
|
||||
driver.setBoard(step)
|
||||
await driver.settle()
|
||||
@@ -116,6 +142,28 @@ async function runStep(driver: ProgramDriver, step: Step) {
|
||||
}
|
||||
}
|
||||
|
||||
interface GridColumn {
|
||||
name: string
|
||||
header: string
|
||||
}
|
||||
|
||||
function gridColumns(node: ControlNode): GridColumn[] {
|
||||
return (node.props.Columns as GridColumn[] | undefined) ?? []
|
||||
}
|
||||
|
||||
/** 数据表格的数据行(不含最下面供新增的空行),每行按列序号排列的显示文字 */
|
||||
function gridRows(node: ControlNode): string[][] {
|
||||
const rows = (node.props.Rows as { items: { cells: unknown[]; isNew: boolean }[] } | undefined)?.items ?? []
|
||||
return rows.filter((r) => !r.isNew).map((r) => r.cells.map((c) => (c === true ? 'True' : c === false ? 'False' : String(c ?? ''))))
|
||||
}
|
||||
|
||||
function rowMatches(node: ControlNode, cells: Record<string, string>) {
|
||||
const columns = gridColumns(node)
|
||||
const index = Object.keys(cells).map((key) => columns.findIndex((c) => c.header === key || c.name === key))
|
||||
if (index.some((i) => i < 0)) return false
|
||||
return gridRows(node).some((row) => Object.values(cells).every((value, i) => row[index[i]] === value))
|
||||
}
|
||||
|
||||
const seconds = (ms: number) => `${Math.round(ms / 100) / 10} 秒`
|
||||
const quote = (value: unknown) => (value === undefined || value === null || value === '' ? '空的' : `"${String(value)}"`)
|
||||
|
||||
@@ -171,6 +219,10 @@ function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'uncha
|
||||
}
|
||||
case 'savedImage':
|
||||
return driver.savedImages.some((path) => path.includes(e.path))
|
||||
case 'row': {
|
||||
const node = driver.find(e.control)
|
||||
return node !== undefined && rowMatches(node, e.cells) === e.present
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,5 +266,15 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
|
||||
const saved = driver.savedImages.map((p) => `"${p}"`).join('、')
|
||||
return `期望${prefix}${desc ?? `保存图片到"${e.path}"`},${saved ? `实际保存的是 ${saved}` : '实际没有保存图片'}`
|
||||
}
|
||||
case 'row': {
|
||||
const node = need(driver, e.control)
|
||||
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('、') || '(没有数据)'}`
|
||||
const rows = gridRows(node)
|
||||
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 ? ' ……' : ''}` : '没有数据'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function createGradingQueue(db: Database, onGraded: (submissionId: number
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await Bun.write(target, file.content)
|
||||
}
|
||||
saveGrade(db, id, await grade(dir, checks))
|
||||
saveGrade(db, id, await grade(dir, checks, submission.sampleId))
|
||||
onGraded(id)
|
||||
} catch (error) {
|
||||
console.error(`评分出错(提交 ${id}):`, error)
|
||||
|
||||
+9
-4
@@ -7,7 +7,9 @@ import { languageService } from './language-service'
|
||||
import { config } from './config'
|
||||
import { openDatabase } from './db'
|
||||
import { createGradingQueue } from './grading/queue'
|
||||
import { userDatabaseDir } from './databases'
|
||||
import { cleanupStaleRuns, RunSession } from './run-session'
|
||||
import { sqlService } from './sql-service'
|
||||
import { checkSandbox, sandboxError } from './sandbox'
|
||||
|
||||
await cleanupStaleRuns()
|
||||
@@ -20,6 +22,7 @@ const aiQueue = createAiQueue(db)
|
||||
const app = createApp(db, createGradingQueue(db, (id) => aiQueue.enqueue(id)), aiQueue)
|
||||
|
||||
interface SocketData {
|
||||
userId?: number
|
||||
session?: RunSession
|
||||
}
|
||||
|
||||
@@ -29,8 +32,9 @@ const server = Bun.serve({
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/ws') {
|
||||
// 浏览器的 WebSocket 不能带请求头,令牌放在网址参数里
|
||||
if (!(await tokenUser(db, url.searchParams.get('token')))) return new Response('请先登录', { status: 401 })
|
||||
if (server.upgrade(req, { data: {} })) return
|
||||
const user = await tokenUser(db, url.searchParams.get('token'))
|
||||
if (!user) return new Response('请先登录', { status: 401 })
|
||||
if (server.upgrade(req, { data: { userId: user.id } })) return
|
||||
return new Response('WebSocket 升级失败', { status: 400 })
|
||||
}
|
||||
return app.fetch(req, server)
|
||||
@@ -38,7 +42,8 @@ const server = Bun.serve({
|
||||
websocket: {
|
||||
data: {} as SocketData,
|
||||
open(ws) {
|
||||
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)))
|
||||
const userId = ws.data.userId!
|
||||
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId))
|
||||
},
|
||||
async message(ws, raw) {
|
||||
let message: ClientMessage
|
||||
@@ -72,5 +77,5 @@ const server = Bun.serve({
|
||||
})
|
||||
|
||||
console.log(`服务器已启动:http://localhost:${server.port}`)
|
||||
await Promise.all([compileService.start(), languageService.start()])
|
||||
await Promise.all([compileService.start(), languageService.start(), sqlService.start()])
|
||||
console.log('C# 编译服务已就绪')
|
||||
@@ -31,7 +31,11 @@ export class RunSession {
|
||||
/** 同一会话的多次"运行"排队依次执行,避免两次启动交错 */
|
||||
private queue = Promise.resolve()
|
||||
|
||||
constructor(private readonly send: (message: ServerMessage) => void) {
|
||||
/** dbDir:这个用户的数据库目录(运行时挂进沙箱,程序可以读写) */
|
||||
constructor(
|
||||
private readonly send: (message: ServerMessage) => void,
|
||||
private readonly dbDir?: () => Promise<string>,
|
||||
) {
|
||||
this.sendBoard()
|
||||
}
|
||||
|
||||
@@ -75,7 +79,7 @@ export class RunSession {
|
||||
}
|
||||
|
||||
// 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息
|
||||
const { cmd, env } = runCommand(dir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) })
|
||||
const { cmd, env } = runCommand(dir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) }, await this.dbDir?.())
|
||||
const proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env })
|
||||
this.proc = proc
|
||||
liveRuns++
|
||||
|
||||
@@ -8,6 +8,7 @@ import { config } from './config'
|
||||
|
||||
const RUNNER_DIR = '/sim/runner'
|
||||
const APP_DIR = '/sim/app'
|
||||
const DB_DIR = '/sim/db'
|
||||
|
||||
/** 学生程序的全部环境变量(不继承服务器的) */
|
||||
function runnerEnv(extra: Record<string, string>) {
|
||||
@@ -62,18 +63,23 @@ export async function checkSandbox() {
|
||||
'Ubuntu 23.10 及以上的宿主机还要执行 sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0(见 deploy/README.md)'
|
||||
}
|
||||
|
||||
/** 启动一次学生程序运行的命令行和环境变量 */
|
||||
export function runCommand(projectDir: string, extraEnv: Record<string, string>) {
|
||||
const env = runnerEnv(extraEnv)
|
||||
/**
|
||||
* 启动一次学生程序运行的命令行和环境变量。
|
||||
* dbDir:这次运行使用的数据库目录(学生自己的,或评分时的临时目录),学生程序可以读写;不给时程序连不上数据库。
|
||||
*/
|
||||
export function runCommand(projectDir: string, extraEnv: Record<string, string>, dbDir?: string) {
|
||||
if (config.sandbox === 'none') {
|
||||
const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: dbDir } : {}) })
|
||||
return { cmd: ['dotnet', config.runnerDll, 'run', projectDir], env }
|
||||
}
|
||||
const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: DB_DIR } : {}) })
|
||||
const cmd = [
|
||||
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
|
||||
'nice', '-n', '10',
|
||||
// CPU 时间用完先收到 SIGXCPU 结束,过几秒还在就强制结束
|
||||
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds + 5}`,
|
||||
...bwrapArgs(projectDir),
|
||||
...(dbDir ? ['--bind', dbDir, DB_DIR] : []),
|
||||
'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR,
|
||||
]
|
||||
return { cmd, env }
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Subprocess } from 'bun'
|
||||
import { config } from './config'
|
||||
import { readLines } from './runner-process'
|
||||
|
||||
/** 查询窗口的一个结果集 */
|
||||
export interface SqlResultSet {
|
||||
columns: { name: string; type: string | null }[]
|
||||
rows: unknown[][]
|
||||
/** 实际行数(只返回前 1000 行) */
|
||||
total: number
|
||||
}
|
||||
|
||||
export type SqlReply =
|
||||
| { type: 'result'; database: string; results: SqlResultSet[]; affected: number; ms: number }
|
||||
| { type: 'done' }
|
||||
| { type: 'explorer'; databases: { name: string; tables: { name: string; columns: SqlColumnInfo[] }[] }[] }
|
||||
| { type: 'error'; number: number; message: string; line: number }
|
||||
|
||||
export interface SqlColumnInfo {
|
||||
name: string
|
||||
type: string
|
||||
nullable: boolean
|
||||
identity: boolean
|
||||
primaryKey: boolean
|
||||
}
|
||||
|
||||
export type SqlRequest =
|
||||
| { 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 {
|
||||
constructor(
|
||||
readonly number: number,
|
||||
message: string,
|
||||
readonly line: number,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 常驻 SQL 服务(C# 进程,T-SQL 转写成 SQLite 执行):数据库页面的查询和对象资源管理器、评分前准备实验数据库。
|
||||
* 进程意外退出时挂起的请求全部失败,下一次请求自动重启。
|
||||
*/
|
||||
class SqlService {
|
||||
private proc?: Subprocess<'pipe', 'pipe', 'inherit'>
|
||||
private ready?: Promise<void>
|
||||
private nextId = 1
|
||||
private readonly pending = new Map<number, { resolve: (r: SqlReply) => void; reject: (e: Error) => void }>()
|
||||
|
||||
/** 发出请求;SQL 报错时抛出 SqlError */
|
||||
async request(request: SqlRequest): Promise<Exclude<SqlReply, { type: 'error' }>> {
|
||||
await this.start()
|
||||
const id = this.nextId++
|
||||
const reply = await new Promise<SqlReply>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.proc!.stdin.write(`${JSON.stringify({ id, ...request })}\n`)
|
||||
this.proc!.stdin.flush()
|
||||
})
|
||||
if (reply.type === 'error') throw new SqlError(reply.number, reply.message, reply.line)
|
||||
return reply
|
||||
}
|
||||
|
||||
stop() {
|
||||
const proc = this.proc
|
||||
this.proc = undefined
|
||||
this.ready = undefined
|
||||
for (const waiter of this.pending.values()) waiter.reject(new Error('SQL 服务已停止'))
|
||||
this.pending.clear()
|
||||
proc?.kill()
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.ready) return this.ready
|
||||
const proc = Bun.spawn(['dotnet', config.runnerDll, 'sql-server'], {
|
||||
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 },
|
||||
})
|
||||
this.proc = proc
|
||||
this.ready = new Promise<void>((resolveReady) => {
|
||||
void readLines(proc.stdout, (line) => {
|
||||
const message = JSON.parse(line) as ({ type: 'ready' } | SqlReply) & { id?: number }
|
||||
if (message.type === 'ready') {
|
||||
resolveReady()
|
||||
return
|
||||
}
|
||||
const waiter = this.pending.get(message.id!)
|
||||
if (!waiter) return
|
||||
this.pending.delete(message.id!)
|
||||
waiter.resolve(message)
|
||||
})
|
||||
})
|
||||
proc.exited.then((code) => {
|
||||
if (this.proc !== proc) return
|
||||
this.proc = undefined
|
||||
this.ready = undefined
|
||||
for (const waiter of this.pending.values()) waiter.reject(new Error(`SQL 服务意外退出(${code})`))
|
||||
this.pending.clear()
|
||||
})
|
||||
return this.ready
|
||||
}
|
||||
}
|
||||
|
||||
export const sqlService = new SqlService()
|
||||
@@ -1,5 +1,6 @@
|
||||
// HTTP 接口测试:内存数据库,直接调用 app.fetch。
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { createAiQueue, normalizeAiResult } from '../src/ai-review'
|
||||
import { createApp } from '../src/app'
|
||||
import { ensureTeacher } from '../src/auth'
|
||||
@@ -7,6 +8,7 @@ import { config } from '../src/config'
|
||||
import { openDatabase } from '../src/db'
|
||||
import { createGradingQueue, type GradingQueue } from '../src/grading/queue'
|
||||
import { languageService } from '../src/language-service'
|
||||
import { sqlService } from '../src/sql-service'
|
||||
import { loadSample } from '../src/samples'
|
||||
|
||||
let app: ReturnType<typeof createApp>
|
||||
@@ -434,6 +436,55 @@ describe('语言服务', () => {
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
describe('数据库页面', () => {
|
||||
const saved = config.dataDir
|
||||
// 测试的用户编号会和本机开发环境重复,数据库放到单独的目录
|
||||
beforeAll(async () => {
|
||||
config.dataDir = `${config.runsDir}/test-api-data`
|
||||
await rm(config.dataDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(() => {
|
||||
config.dataDir = saved
|
||||
sqlService.stop()
|
||||
})
|
||||
|
||||
test('建库建表、查询;SQL 报错和 SSMS 一样返回编号和信息;每个人只看到自己的库', async () => {
|
||||
const t = await teacher()
|
||||
await classWithStudents(t, [{ username: 's1', name: '甲', password: 'p1' }])
|
||||
const student = new Browser()
|
||||
await student.login('s1', 'p1')
|
||||
|
||||
expect((await t.post('/api/db/query', { database: 'master', sql: 'CREATE DATABASE 测试库' })).body).toMatchObject({ type: 'result', affected: -1 })
|
||||
const created = await t.post('/api/db/query', {
|
||||
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 }] })
|
||||
|
||||
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((await student.get('/api/db')).body.databases).toEqual([])
|
||||
expect((await new Browser().get('/api/db')).status).toBe(401)
|
||||
}, 60000)
|
||||
|
||||
test('恢复实验初始数据:只替换实验用到的库', async () => {
|
||||
const t = await teacher()
|
||||
await t.post('/api/db/query', { database: 'master', sql: 'CREATE DATABASE 我的库' })
|
||||
expect((await t.get('/api/db/experiments')).body.map((e: { id: string }) => e.id)).toContain('p6-t2-存储环境数据')
|
||||
expect((await t.post('/api/db/restore', { sampleId: 'p6-t2-存储环境数据' })).body).toEqual({ databases: ['环境数据表'] })
|
||||
const rows = await t.post('/api/db/query', { database: '环境数据表', sql: 'SELECT COUNT(*) FROM CampusEnvironment' })
|
||||
expect(rows.body.results[0].rows).toEqual([[3]])
|
||||
expect((await t.get('/api/db')).body.databases.map((d: { name: string }) => d.name)).toEqual(['我的库', '测试库', '环境数据表'])
|
||||
expect((await t.post('/api/db/restore', { sampleId: '../etc' })).status).toBe(400)
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
describe('AI 评阅', () => {
|
||||
const SAMPLE = 'p4-t3-报警灯'
|
||||
const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }, { title: '注释规范', points: 5 }] }
|
||||
|
||||
+33
-11
@@ -3,11 +3,15 @@ import { afterAll, describe, expect, test } from 'bun:test'
|
||||
import { cp, rm } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { compileService } from '../src/compile-service'
|
||||
import { sqlService } from '../src/sql-service'
|
||||
import { ROOT } from '../src/config'
|
||||
import { parseChecks } from '../src/grading/checks'
|
||||
import { grade } from '../src/grading/grader'
|
||||
|
||||
afterAll(() => compileService.stop())
|
||||
afterAll(() => {
|
||||
compileService.stop()
|
||||
sqlService.stop()
|
||||
})
|
||||
|
||||
const SAMPLES = resolve(ROOT, 'samples')
|
||||
|
||||
@@ -31,10 +35,10 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st
|
||||
|
||||
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(13))
|
||||
test('每个示例都有评分规则', () => expect(ids.length).toBe(15))
|
||||
for (const id of ids) {
|
||||
test(id, async () => {
|
||||
const result = await grade(await prepare(id, id), await checksOf(id))
|
||||
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)
|
||||
@@ -45,7 +49,7 @@ describe('改坏的代码', () => {
|
||||
test('编译不通过时所有项都不通过', async () => {
|
||||
const id = 'p2-t1-LED显示屏'
|
||||
const dir = await prepare(id, 'compile-error', { file: 'Form1.cs', from: 'led.setLedText(res);', to: 'led.setLedText(res)' })
|
||||
const result = await grade(dir, await checksOf(id))
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
expect(result.compiled).toBe(false)
|
||||
expect(result.results.every((r) => !r.passed && r.message === '编译没有通过,先改正编译错误')).toBe(true)
|
||||
}, 60000)
|
||||
@@ -56,14 +60,14 @@ describe('改坏的代码', () => {
|
||||
// 同时把 Designer.cs 里的控件改名,模拟学生没按书上命名
|
||||
const designer = resolve(dir, 'Form1.Designer.cs')
|
||||
await Bun.write(designer, (await Bun.file(designer).text()).replaceAll('txt_SendText', 'textBox1'))
|
||||
const result = await grade(dir, await checksOf(id))
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
expect(result.results[0]).toMatchObject({ passed: false, message: '找不到名为 txt_SendText 的控件(书上规定的控件名,注意大小写)' })
|
||||
}, 60000)
|
||||
|
||||
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 result = await grade(dir, await checksOf(id))
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
const realtime = result.results.find((r) => r.title === '能实时显示入侵数据')!
|
||||
expect(realtime.passed).toBe(false)
|
||||
expect(realtime.message).toBe('期望6 秒内 txt_even 的文字包含"发现入侵",实际是"正常"')
|
||||
@@ -74,7 +78,7 @@ describe('改坏的代码', () => {
|
||||
test('关闭时忘了停定时器', async () => {
|
||||
const id = 'p4-t1-红外对射'
|
||||
const dir = await prepare(id, 'timer', { file: 'Form1.cs', from: 'timer1.Stop();', to: '' })
|
||||
const result = await grade(dir, await checksOf(id))
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
const stop = result.results.find((r) => r.title === '再点按钮能关闭串口、停止定时器')!
|
||||
expect(stop.passed).toBe(false)
|
||||
expect(stop.message).toStartWith('期望 txt_time 不再更新时间(定时器应已停止),实际从')
|
||||
@@ -83,15 +87,33 @@ describe('改坏的代码', () => {
|
||||
test('摄像机:忘了绑定画面事件', async () => {
|
||||
const id = 'p4-t2-网络摄像机'
|
||||
const dir = await prepare(id, 'camera', { file: 'Form1.cs', from: 'DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;', to: '' })
|
||||
const result = await grade(dir, await checksOf(id))
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
expect(result.results.map((r) => r.passed)).toEqual([true, false, true, false])
|
||||
expect(result.results[1].message).toBe('期望3 秒内 pictureBox1 显示摄像机拍到的图像,实际是空的')
|
||||
}, 60000)
|
||||
|
||||
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 result = await grade(dir, await checksOf(id), id)
|
||||
const save = result.results.find((r) => r.title === '能把数据保存到数据库,并显示在表格中')!
|
||||
expect(save.passed).toBe(false)
|
||||
expect(save.message).toContain('期望数据表格 dataGridView1 表格里有刚保存的一行,温度、湿度、光照度各在对应的列,实际表格里是:')
|
||||
expect(save.message).toContain('4 | 733 | 42 | 31')
|
||||
}, 60000)
|
||||
|
||||
test('数据库:照书上漏写 ds = new DataSet(),查询时程序出错', async () => {
|
||||
const id = 'p6-t3-按时间查询'
|
||||
const dir = await prepare(id, 'no-dataset', { file: 'Form1.cs', from: ' ds = new DataSet();\n', to: '' })
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
expect(result.results[0].passed).toBe(false)
|
||||
expect(result.results[0].message).toStartWith('程序出错:System.ArgumentNullException')
|
||||
}, 60000)
|
||||
|
||||
test('关闭时忘了挂起线程', async () => {
|
||||
const id = 'p5-t2-烟雾'
|
||||
const dir = await prepare(id, 'thread', { file: 'Form1.cs', from: 'thd.Suspend();//挂起线程', to: '' })
|
||||
const result = await grade(dir, await checksOf(id))
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
const stop = result.results.find((r) => r.title === '再点按钮能关闭串口、挂起线程,再次打开时线程继续')!
|
||||
expect(stop.passed).toBe(false)
|
||||
expect(stop.message).toStartWith('期望 txt_time 不再更新时间(线程应已挂起),实际从')
|
||||
@@ -100,14 +122,14 @@ describe('改坏的代码', () => {
|
||||
test('历史灾情窗体没有把数组里的记录加入列表框', async () => {
|
||||
const id = 'p5-t3-推送灾情'
|
||||
const dir = await prepare(id, 'listbox', { file: 'Form2.cs', from: 'lstbRecored.Items.Add(', to: 'Console.WriteLine(' })
|
||||
const result = await grade(dir, await checksOf(id))
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
expect(result.results[2]).toMatchObject({ passed: false, message: '期望 lstbRecored 列表里有"发现火情"的记录,实际列表是空的' })
|
||||
}, 60000)
|
||||
|
||||
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 result = await grade(dir, await checksOf(id))
|
||||
const result = await grade(dir, await checksOf(id), id)
|
||||
expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' })
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材示例工程的行为。
|
||||
// 运行前先编译运行时:bun run build:runtime
|
||||
import { afterAll, afterEach, describe, expect, test } from 'bun:test'
|
||||
import { cp, rm } from 'node:fs/promises'
|
||||
import { cp, mkdir, rm } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared'
|
||||
import { compileService } from '../src/compile-service'
|
||||
@@ -15,12 +15,12 @@ class Runner {
|
||||
private waiters: { match: (m: RunnerMessage) => boolean; resolve: (m: RunnerMessage) => void }[] = []
|
||||
|
||||
/** 与服务器相同的流程:先由编译服务编译,成功后启动运行进程 */
|
||||
static async start(projectDir: string) {
|
||||
static async start(projectDir: string, dbDir?: string) {
|
||||
const runner = new Runner()
|
||||
const compiled = await compileService.compile(projectDir)
|
||||
runner.receive({ type: 'compiled', ...compiled })
|
||||
if (compiled.ok) {
|
||||
const { cmd, env } = runCommand(projectDir, {})
|
||||
const { cmd, env } = runCommand(projectDir, {}, dbDir)
|
||||
runner.proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit', env })
|
||||
runner.reading = runner.read()
|
||||
}
|
||||
@@ -417,14 +417,14 @@ test('项目5 任务2:照书上直接 Abort 挂起的线程,和 .NET Framewo
|
||||
}, 30000)
|
||||
|
||||
/** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */
|
||||
async function runMain(name: string, body: string, usings = '') {
|
||||
async function runMain(name: string, body: string, usings = '', dbDir?: string) {
|
||||
const dir = resolve(ROOT, '.runs', `test-${name}`)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
await Bun.write(
|
||||
`${dir}/Program.cs`,
|
||||
`using System;\nusing System.IO;\nusing System.Linq;\n${usings}\nstatic class Program\n{\n static void Main()\n {\n${body}\n }\n}\n`,
|
||||
)
|
||||
runner = await Runner.start(dir)
|
||||
runner = await Runner.start(dir, dbDir)
|
||||
return { runner, dir }
|
||||
}
|
||||
|
||||
@@ -544,6 +544,92 @@ test('图片:程序所在目录可写,Image.Save / FromFile 往返,文件
|
||||
expect(runner.messages.filter((m) => m.type === 'imageSaved').map((m) => (m as { path: string }).path)).toEqual(['Pics/a.png'])
|
||||
}, 30000)
|
||||
|
||||
test('数据库:照书上的写法建库建表、插入、查询、SqlCommandBuilder 修改、删除、按时间段查询;报错和 SQL Server 一样', async () => {
|
||||
const dbDir = resolve(ROOT, '.runs', 'test-db-data')
|
||||
await rm(dbDir, { recursive: true, force: true })
|
||||
await mkdir(dbDir, { recursive: true })
|
||||
const { runner } = await runMain(
|
||||
'database',
|
||||
`
|
||||
var master = new SqlConnection(@"Data Source= PC-20150908SCJF\\SQLSERVER1026;User ID=sa;Password=123456");
|
||||
master.Open();
|
||||
new SqlCommand("CREATE DATABASE 环境数据表", master).ExecuteNonQuery();
|
||||
master.Close();
|
||||
|
||||
string cs = @"Data Source=.;Initial Catalog=环境数据表;User ID=sa;Password=123456";
|
||||
var conn = new SqlConnection(cs);
|
||||
conn.Open();
|
||||
new SqlCommand("CREATE TABLE CampusEnvironment (ID int IDENTITY(1,1) PRIMARY KEY, light varchar(50), humidity varchar(50), temperature varchar(50), addtime datetime)", conn).ExecuteNonQuery();
|
||||
// 书上的写法:不写列名,值的顺序是温度、湿度、光照(和建表的列顺序不一样)
|
||||
string temperature = "25", humidity = "60", light = "500";
|
||||
int n = new SqlCommand("insert into CampusEnvironment values (" + temperature + "," + humidity + "," + light + ", '" + new DateTime(2024, 1, 16, 9, 5, 3).ToString() + "')", conn).ExecuteNonQuery();
|
||||
Console.WriteLine("inserted=" + n);
|
||||
new SqlCommand("insert into CampusEnvironment (light, humidity, temperature, addtime) values ('300', '55', '18', '2024/1/20 14:42:00')", conn).ExecuteNonQuery();
|
||||
new SqlCommand("insert into CampusEnvironment (light, humidity, temperature, addtime) values ('100', '50', '20', '2024-01-25 08:00:00')", conn).ExecuteNonQuery();
|
||||
|
||||
var adapter = new SqlDataAdapter(new SqlCommand("select ID as '编号',temperature as '温度',humidity as '湿度',light as '光照度', addtime as '时间' from CampusEnvironment", conn));
|
||||
var ds = new DataSet();
|
||||
adapter.Fill(ds);
|
||||
var t = ds.Tables[0];
|
||||
Console.WriteLine("columns=" + string.Join(",", t.Columns.Cast<DataColumn>().Select(c => c.ColumnName + ":" + c.DataType.Name)));
|
||||
Console.WriteLine("row0=" + string.Join("|", t.Rows[0].ItemArray));
|
||||
|
||||
// 修改:改 DataTable 里的一格,SqlCommandBuilder 生成 UPDATE
|
||||
var builder = new SqlCommandBuilder(adapter);
|
||||
adapter.UpdateCommand = builder.GetUpdateCommand();
|
||||
t.Rows[0]["温度"] = "26";
|
||||
Console.WriteLine("updated=" + adapter.Update(t));
|
||||
Console.WriteLine("check=" + new SqlCommand("select temperature from CampusEnvironment where ID=1", conn).ExecuteScalar());
|
||||
|
||||
Console.WriteLine("deleted=" + new SqlCommand("delete from CampusEnvironment where id=" + t.Rows[2][0].ToString(), conn).ExecuteNonQuery());
|
||||
|
||||
// 任务 3:BETWEEN 拼接 DateTime.ToString()(中文格式)
|
||||
var ds2 = new DataSet();
|
||||
new SqlDataAdapter(new SqlCommand("select * from CampusEnvironment where addtime BETWEEN '" + new DateTime(2024, 1, 16).ToString() + "' AND '" + new DateTime(2024, 1, 18).ToString() + "'", conn)).Fill(ds2);
|
||||
Console.WriteLine("between=" + ds2.Tables[0].Rows.Count);
|
||||
var reader = new SqlCommand("select count(*), max(addtime), CONVERT(varchar(10), addtime, 120) as d from CampusEnvironment group by CONVERT(varchar(10), addtime, 120) order by d", conn).ExecuteReader();
|
||||
while (reader.Read()) Console.WriteLine("group=" + reader.GetInt32(0) + "," + reader.GetDateTime(1).ToString("HH:mm") + "," + reader["d"]);
|
||||
reader.Close();
|
||||
|
||||
foreach (var sql in new[] { "select * from NoSuchTable", "selec * from CampusEnvironment", "insert into CampusEnvironment values (1, 2)", "insert into CampusEnvironment (ID, light) values (5, '1')", "select nosuch from CampusEnvironment", "insert into CampusEnvironment (addtime) values ('abc')" })
|
||||
{
|
||||
try { new SqlCommand(sql, conn).ExecuteNonQuery(); }
|
||||
catch (SqlException ex) { Console.WriteLine("error=" + ex.Number + " " + ex.Message); }
|
||||
}
|
||||
try { new SqlConnection("Data Source=.;Initial Catalog=NoDb").Open(); }
|
||||
catch (SqlException ex) { Console.WriteLine("error=" + ex.Number + " " + ex.Message); }`,
|
||||
'using System.Data;\nusing System.Data.SqlClient;',
|
||||
dbDir,
|
||||
)
|
||||
const compiled = await runner.waitFor('compiled')
|
||||
expect(compiled.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
|
||||
await runner.finished()
|
||||
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
|
||||
const errors = runner.messages.filter((m) => m.type === 'exception')
|
||||
expect(errors).toEqual([])
|
||||
for (const line of [
|
||||
'inserted=1',
|
||||
'columns=编号:Int32,温度:String,湿度:String,光照度:String,时间:DateTime',
|
||||
// 书上的列顺序错位:光照存了温度的值
|
||||
'row0=1|500|60|25|2024/1/16 9:05:03',
|
||||
'updated=1',
|
||||
'check=26',
|
||||
'deleted=1',
|
||||
'between=1',
|
||||
'group=1,09:05,2024-01-16',
|
||||
'group=1,14:42,2024-01-20',
|
||||
"error=208 对象名 'NoSuchTable' 无效。",
|
||||
"error=102 'selec' 附近有语法错误。",
|
||||
'error=213 列名或所提供值的数目与表定义不匹配。',
|
||||
"error=544 当 IDENTITY_INSERT 设置为 OFF 时,不能为表 'CampusEnvironment' 中的标识列插入显式值。",
|
||||
"error=207 列名 'nosuch' 无效。",
|
||||
'error=241 从字符串转换日期和/或时间时,转换失败。',
|
||||
'error=4060 无法打开登录所请求的数据库 "NoDb"。登录失败。',
|
||||
]) {
|
||||
expect(output).toContain(line)
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
|
||||
process.env.SIM_TEST_SECRET = '不该被看到'
|
||||
const { runner } = await runMain(
|
||||
|
||||
Reference in new issue
Block a user