项目 6 数据存储:仿冒 SqlClient(T-SQL 转写成 SQLite)、数据库页面、DataGridView、DateTimePicker
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 每个学生一套数据库,长期保存(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:
xuyueandClaude Opus 5.5 committed 2026-10-23 09:18:56 +08:00
1 parent 6b088240da
commit 670abe5921
72 files changed
+7253 -51

No files matched your search

+51
View File
@@ -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
View File
@@ -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)
})
+91 -5
View File
@@ -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(