Files
Web_BLV_OA_Exam_Prod/test/question-text-import.test.ts
XuJiacheng dbf9fdc01c 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 的问题。
2026-01-04 09:20:04 +08:00

394 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
process.env.NODE_ENV = 'test';
process.env.DB_PATH = ':memory:';
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 };
};
test('题库文本导入增量模式按内容覆盖', async () => {
const { initDatabase, run, 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 oldId = randomUUID();
await run(
`INSERT INTO questions (id, content, type, options, answer, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[oldId, '重复题目', 'single', JSON.stringify(['A', 'B']), 'A', 5, '通用'],
);
const res = await jsonFetch(baseUrl, '/api/questions/import-text', {
method: 'POST',
body: {
mode: 'incremental',
questions: [
{
content: '重复题目',
type: 'multiple',
category: '数学',
options: ['选项A', '选项B', '选项C'],
answer: ['选项A', '选项C'],
analysis: '解析:这是一道示例题',
score: 10,
},
],
},
});
assert.equal(res.status, 200);
assert.equal(res.json?.success, true);
assert.equal(res.json?.data?.inserted, 0);
assert.equal(res.json?.data?.updated, 1);
const row = await get(`SELECT * FROM questions WHERE content = ?`, ['重复题目']);
assert.equal(row.id, oldId);
assert.equal(row.type, 'multiple');
assert.equal(row.category, '数学');
assert.equal(row.score, 10);
assert.equal(row.analysis, '解析:这是一道示例题');
assert.equal(JSON.parse(row.options).length, 3);
assert.deepEqual(JSON.parse(row.answer), ['选项A', '选项C']);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('题库文本导入覆盖模式清空题库与答题记录', async () => {
const { initDatabase, run, 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 userId = randomUUID();
const subjectId = randomUUID();
const oldQuestionId = randomUUID();
const recordId = randomUUID();
const answerId = randomUUID();
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
userId,
'测试用户',
`138${Math.floor(Math.random() * 1e8).toString().padStart(8, '0')}`,
'',
]);
await run(
`INSERT INTO exam_subjects (id, name, type_ratios, category_ratios, total_score, duration_minutes)
VALUES (?, ?, ?, ?, ?, ?)`,
[
subjectId,
'测试科目',
JSON.stringify({ single: 100 }),
JSON.stringify({ 通用: 100 }),
100,
60,
],
);
await run(
`INSERT INTO questions (id, content, type, options, answer, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[oldQuestionId, '旧题目', 'single', JSON.stringify(['A', 'B']), 'A', 5, '通用'],
);
await run(
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[recordId, userId, subjectId, null, 5, 1, 1, new Date().toISOString()],
);
await run(
`INSERT INTO quiz_answers (id, record_id, question_id, user_answer, score, is_correct, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[answerId, recordId, oldQuestionId, 'A', 5, 1, new Date().toISOString()],
);
const res = await jsonFetch(baseUrl, '/api/questions/import-text', {
method: 'POST',
body: {
mode: 'overwrite',
questions: [
{
content: '新题目',
type: 'single',
category: '通用',
options: ['A', 'B'],
answer: 'A',
analysis: '解析:新题目的说明',
score: 5,
},
],
},
});
assert.equal(res.status, 200);
assert.equal(res.json?.success, true);
assert.equal(res.json?.data?.inserted, 1);
assert.equal(res.json?.data?.updated, 0);
const questionCount = await get(`SELECT COUNT(*) as total FROM questions`);
const recordCount = await get(`SELECT COUNT(*) as total FROM quiz_records`);
const answerCount = await get(`SELECT COUNT(*) as total FROM quiz_answers`);
assert.equal(questionCount.total, 1);
assert.equal(recordCount.total, 0);
assert.equal(answerCount.total, 0);
const row = await get(`SELECT * FROM questions WHERE content = ?`, ['新题目']);
assert.equal(row.analysis, '解析:新题目的说明');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('答题记录详情应返回题目解析字段', async () => {
const { initDatabase, run } = 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 userId = randomUUID();
const questionId = randomUUID();
const recordId = randomUUID();
const answerId = randomUUID();
const createdAt = new Date().toISOString();
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
userId,
'测试用户',
'13800138000',
'',
]);
await run(
`INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[questionId, '带解析题目', 'single', JSON.stringify(['A', 'B']), 'A', '解析内容', 5, '通用'],
);
await run(
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[recordId, userId, null, null, 5, 1, 1, createdAt],
);
await run(
`INSERT INTO quiz_answers (id, record_id, question_id, user_answer, score, is_correct, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[answerId, recordId, questionId, 'A', 5, 1, createdAt],
);
const res = await jsonFetch(baseUrl, `/api/quiz/records/detail/${recordId}`);
assert.equal(res.status, 200);
assert.equal(res.json?.success, true);
assert.equal(res.json?.data?.record?.id, recordId);
assert.equal(Array.isArray(res.json?.data?.answers), true);
assert.equal(res.json?.data?.answers?.[0]?.questionAnalysis, '解析内容');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('前端文本解析支持 | 分隔格式', async () => {
const { parseTextQuestions } = await import('../src/utils/questionTextImport');
const input = [
'题型|题目类别|分值|题目内容|选项|答案|解析',
'单选|通用|5|我国首都是哪里?|北京|上海|广州|深圳|A|我国首都为北京',
'多选|通用|5|以下哪些是水果?|苹果|白菜|香蕉|西红柿|A,C,D|水果包括苹果/香蕉/西红柿',
'判断|通用|0|地球是圆的||正确|地球接近球体',
].join('\n');
const res = parseTextQuestions(input);
assert.deepEqual(res.errors, []);
assert.equal(res.questions.length, 3);
const single = res.questions.find((q: any) => q.type === 'single');
assert.ok(single);
assert.deepEqual(single.options, ['北京', '上海', '广州', '深圳']);
assert.equal(single.answer, '北京');
assert.equal(single.analysis, '我国首都为北京');
const multiple = res.questions.find((q: any) => q.type === 'multiple');
assert.ok(multiple);
assert.deepEqual(multiple.options, ['苹果', '白菜', '香蕉', '西红柿']);
assert.deepEqual(multiple.answer, ['苹果', '香蕉', '西红柿']);
assert.equal(multiple.analysis, '水果包括苹果/香蕉/西红柿');
const judgment = res.questions.find((q: any) => q.type === 'judgment');
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 () => {
const { initDatabase, run } = 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}`;
await run(`DELETE FROM quiz_answers`);
await run(`DELETE FROM quiz_records`);
await run(`DELETE FROM questions`);
await run(`DELETE FROM question_categories`);
await run(`INSERT INTO question_categories (id, name) VALUES (?, ?)`, [randomUUID(), '空类别']);
const q1 = randomUUID();
const q2 = randomUUID();
const q3 = randomUUID();
const q4 = randomUUID();
const q5 = randomUUID();
await run(
`INSERT INTO questions (id, content, type, options, answer, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[q1, '数学题1', 'single', JSON.stringify(['A', 'B']), 'A', 5, '数学'],
);
await run(
`INSERT INTO questions (id, content, type, options, answer, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[q2, '数学题2', 'single', JSON.stringify(['A', 'B']), 'A', 5, '数学'],
);
await run(
`INSERT INTO questions (id, content, type, options, answer, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[q3, '英语题1', 'single', JSON.stringify(['A', 'B']), 'A', 5, '英语'],
);
await run(
`INSERT INTO questions (id, content, type, options, answer, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[q4, '通用题1', 'single', JSON.stringify(['A', 'B']), 'A', 5, '通用'],
);
await run(
`INSERT INTO questions (id, content, type, options, answer, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[q5, '空类别题(旧数据)', 'single', JSON.stringify(['A', 'B']), 'A', 5, ''],
);
const res = await jsonFetch(baseUrl, '/api/question-categories');
assert.equal(res.status, 200);
assert.equal(res.json?.success, true);
assert.equal(Array.isArray(res.json?.data), true);
const list = res.json?.data as any[];
const findByName = (name: string) => list.find((c) => c?.name === name);
assert.equal(findByName('数学')?.questionCount, 2);
assert.equal(findByName('英语')?.questionCount, 1);
assert.equal(findByName('通用')?.questionCount, 2);
assert.equal(findByName('空类别')?.questionCount, 0);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});