feat: 修改部分导入文本的逻辑,添加部署脚本和样式文件,更新数据库迁移逻辑
- 新增部署脚本 `build-deploy-bundle.mjs`,用于构建和部署 web 和 server 目录。 - 新增样式文件 `index-acd65452.css`,包含基础样式和响应式设计。 - 新增脚本 `repro-import-text.mjs`,用于测试文本导入 API。 - 新增测试文件 `db-migration-score-zero.test.ts`,验证历史数据库中 questions.score 约束的迁移逻辑。 - 更新数据库初始化逻辑,允许插入 score=0 的问题。
This commit is contained in:
58
test/db-migration-score-zero.test.ts
Normal file
58
test/db-migration-score-zero.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_PATH = ':memory:';
|
||||
|
||||
test('历史数据库 questions.score > 0 约束应迁移为 >= 0', async () => {
|
||||
const { initDbConnection, initDatabase, run } = await import('../api/database');
|
||||
|
||||
// 手工创建“旧版本”最小可启动 schema:让 initDatabase 走“表已存在”分支
|
||||
await initDbConnection();
|
||||
|
||||
await run(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await run(`
|
||||
CREATE TABLE questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
options TEXT,
|
||||
answer TEXT NOT NULL,
|
||||
score INTEGER NOT NULL CHECK(score > 0),
|
||||
category TEXT NOT NULL DEFAULT '通用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await run(`
|
||||
CREATE TABLE quiz_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
subject_id TEXT,
|
||||
task_id TEXT,
|
||||
total_score INTEGER NOT NULL,
|
||||
correct_count INTEGER NOT NULL,
|
||||
total_count INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await initDatabase();
|
||||
|
||||
// 迁移后应允许插入 score=0
|
||||
await assert.doesNotReject(async () => {
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES ('q1', '0分题', 'text', NULL, '', 0, '通用')`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -236,7 +236,7 @@ test('前端文本解析支持 | 分隔格式', async () => {
|
||||
'题型|题目类别|分值|题目内容|选项|答案|解析',
|
||||
'单选|通用|5|我国首都是哪里?|北京|上海|广州|深圳|A|我国首都为北京',
|
||||
'多选|通用|5|以下哪些是水果?|苹果|白菜|香蕉|西红柿|A,C,D|水果包括苹果/香蕉/西红柿',
|
||||
'判断|通用|2|地球是圆的||正确|地球接近球体',
|
||||
'判断|通用|0|地球是圆的||正确|地球接近球体',
|
||||
].join('\n');
|
||||
|
||||
const res = parseTextQuestions(input);
|
||||
@@ -259,6 +259,69 @@ test('前端文本解析支持 | 分隔格式', async () => {
|
||||
assert.ok(judgment);
|
||||
assert.equal(judgment.answer, '正确');
|
||||
assert.equal(judgment.analysis, '地球接近球体');
|
||||
assert.equal(judgment.score, 0);
|
||||
});
|
||||
|
||||
test('前端文本解析:| 分隔时选项允许包含逗号/顿号', async () => {
|
||||
const { parseTextQuestions } = await import('../src/utils/questionTextImport');
|
||||
|
||||
const input = [
|
||||
'题型|题目类别|分值|题目内容|选项|答案|解析',
|
||||
'单选|通用|5|带标点的选项是否应完整保留?|选项A,包含中文逗号|选项B、包含顿号|选项C(括号)|选项D。句号|A|应能正常解析',
|
||||
].join('\n');
|
||||
|
||||
const res = parseTextQuestions(input);
|
||||
assert.deepEqual(res.errors, []);
|
||||
assert.equal(res.questions.length, 1);
|
||||
|
||||
const q = res.questions[0];
|
||||
assert.equal(q.type, 'single');
|
||||
assert.deepEqual(q.options, ['选项A,包含中文逗号', '选项B、包含顿号', '选项C(括号)', '选项D。句号']);
|
||||
assert.equal(q.answer, '选项A,包含中文逗号');
|
||||
assert.equal(q.analysis, '应能正常解析');
|
||||
});
|
||||
|
||||
test('题库文本导入应允许0分题目', async () => {
|
||||
const { initDatabase, get } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const res = await jsonFetch(baseUrl, '/api/questions/import-text', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
mode: 'incremental',
|
||||
questions: [
|
||||
{
|
||||
content: '0分判断题示例',
|
||||
type: 'judgment',
|
||||
category: '通用',
|
||||
options: undefined,
|
||||
answer: '正确',
|
||||
analysis: '该题用于验证0分允许导入',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json?.success, true);
|
||||
assert.equal(res.json?.data?.inserted, 1);
|
||||
|
||||
const row = await get(`SELECT * FROM questions WHERE content = ?`, ['0分判断题示例']);
|
||||
assert.ok(row);
|
||||
assert.equal(row.score, 0);
|
||||
assert.equal(row.type, 'judgment');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('题目类别列表应返回题库数量统计', async () => {
|
||||
|
||||
@@ -109,3 +109,80 @@ test('得分占比=总得分/试卷总分;40%应判定为不及格', async ()
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('0分题目提交时默认判定正确且不要求作答', async () => {
|
||||
const { initDatabase, run, get } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
const server = app.listen(0);
|
||||
|
||||
const jsonFetch = async (baseUrl: string, path: string, options?: { method?: string; body?: unknown }) => {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { status: res.status, json, text };
|
||||
};
|
||||
|
||||
try {
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const userId = randomUUID();
|
||||
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
|
||||
userId,
|
||||
'测试用户',
|
||||
`138${Math.floor(Math.random() * 1e8).toString().padStart(8, '0')}`,
|
||||
'',
|
||||
]);
|
||||
|
||||
const q0 = randomUUID();
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[q0, '0分题', 'single', JSON.stringify(['A', 'B']), '', '用于验证0分默认正确', 0, '通用'],
|
||||
);
|
||||
|
||||
const res = await jsonFetch(baseUrl, '/api/quiz/submit', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId,
|
||||
answers: [
|
||||
{
|
||||
questionId: q0,
|
||||
// 不传 userAnswer
|
||||
score: 0,
|
||||
isCorrect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json?.success, true);
|
||||
assert.equal(res.json?.data?.totalScore, 0);
|
||||
assert.equal(res.json?.data?.correctCount, 1);
|
||||
assert.equal(res.json?.data?.totalCount, 1);
|
||||
|
||||
const recordId = res.json?.data?.recordId;
|
||||
assert.ok(recordId);
|
||||
|
||||
const row = await get(`SELECT * FROM quiz_answers WHERE record_id = ?`, [recordId]);
|
||||
assert.ok(row);
|
||||
assert.equal(row.is_correct, 1);
|
||||
assert.equal(row.score, 0);
|
||||
assert.equal(row.user_answer, '');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user