测试数据生成

This commit is contained in:
2026-07-21 17:52:34 +08:00 Unverified
parent 5477a261f8
commit 607b71e4f0
5 changed files with 199 additions and 75 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"start": "node server.mjs",
"test": "node tests/client-auth.test.mjs && node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/system.test.mjs",
"test": "node tests/client-auth.test.mjs && node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
"test:cache": "node tests/cache.test.mjs",
"reset-db": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs",
+4 -3
View File
@@ -127,8 +127,8 @@ async function prepareMysql() {
let recognizedTestData = false;
if (initializeEmpty && nonEmpty.length) {
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7')");
recognizedTestData = Number(bulkUsers.count) >= 300 && Number(testSchools.count) === 4;
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')");
recognizedTestData = Number(bulkUsers.count) >= 1100 && Number(testSchools.count) === 5;
}
if (nonEmpty.length && !force && !recognizedTestData) {
const forceCommand = initializeEmpty
@@ -193,8 +193,9 @@ if (initializeEmpty) {
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
} else {
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
console.log(`${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
console.log(`${state.schools.filter(item => item.isSourceSchool).length} source schools, ${state.schools.filter(item => item.isAdmissionSchool).length} admission schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
console.log(`${state.results.length} published subject scores, ${state.admissionRecords.filter(item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1).length} first-round preferences`);
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
console.log('all predefined test account passwords: 12345678');
}
+154 -70
View File
@@ -1,4 +1,4 @@
export function createSeedDatabase({ nowIso, hashPassword }) {
export function createSeedDatabase({ nowIso, hashPassword, candidateCount = 1200 }) {
const adminId = 'usr_admin';
const schoolAdminId = 'usr_school_admin';
const schoolAdmin2Id = 'usr_school_admin_2';
@@ -6,6 +6,39 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
const examId = 'exam_autumn_2026';
const registrationId = 'reg_demo_2026';
const testPasswordHash = hashPassword('12345678');
const mainSubjectDefinitions = [
{ id: 'sub_chinese', name: '语文', date: '2026-06-20', start: '09:00', end: '11:00', fullScore: 120 },
{ id: 'sub_math', name: '数学', date: '2026-06-20', start: '14:30', end: '16:30', fullScore: 120 },
{ id: 'sub_english', name: '外语', date: '2026-06-21', start: '09:00', end: '11:00', fullScore: 120 },
{ id: 'sub_history', name: '历史', date: '2026-06-21', start: '14:30', end: '15:45', fullScore: 75 },
{ id: 'sub_politics', name: '政治', date: '2026-06-21', start: '16:10', end: '17:25', fullScore: 75 },
{ id: 'sub_physics', name: '物理', date: '2026-06-22', start: '09:00', end: '10:20', fullScore: 80 },
{ id: 'sub_chemistry', name: '化学', date: '2026-06-22', start: '10:45', end: '12:00', fullScore: 70 },
{ id: 'sub_experiment', name: '实验', date: '2026-06-22', start: '14:30', end: '15:00', fullScore: 20 },
{ id: 'sub_it', name: '信息技术', date: '2026-06-22', start: '15:30', end: '16:00', fullScore: 10 }
].map((subject, index) => ({
...subject, fee: 0, passRule: 'fixed_score', passValue: subject.fullScore * 0.6,
passScore: subject.fullScore * 0.6, order: index + 1
}));
const mainSubjectIds = mainSubjectDefinitions.map(subject => subject.id);
const mainCandidateCount = Math.max(1, Math.trunc(Number(candidateCount) || 1200));
const specialtyCandidateCount = Math.min(150, mainCandidateCount);
// 固定种子使每次导入得到相同的近似正态成绩,便于复现测试。
let randomState = 0x20260620;
const seededRandom = () => {
randomState = (randomState + 0x6D2B79F5) >>> 0;
let value = randomState;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
const normalRandom = () => {
const first = Math.max(seededRandom(), Number.EPSILON);
return Math.sqrt(-2 * Math.log(first)) * Math.cos(2 * Math.PI * seededRandom());
};
const normalScore = fullScore => Number(Math.min(fullScore, Math.max(0, fullScore * 0.72 + fullScore * 0.14 * normalRandom())).toFixed(1));
const scoreGrade = (score, fullScore) => score >= fullScore * 0.9 ? 'A' : score >= fullScore * 0.75 ? 'B' : score >= fullScore * 0.6 ? 'C' : 'D';
const database = {
meta: { version: 15, createdAt: nowIso() },
settings: { selfRegistrationEnabled: false },
@@ -16,8 +49,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
address: '江苏省连云港市海州区文教路 18 号'
},
schools: [
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', active: true },
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', active: true }
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', isSourceSchool: true, isAdmissionSchool: false, active: true },
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', isSourceSchool: true, isAdmissionSchool: false, active: true }
],
classes: [
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
@@ -39,7 +72,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区',
address: '学府路 8 号', emergencyContact: '周建国', emergencyPhone: '13900139000',
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false,
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000',
specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyTypes: ['fine_arts'], specialtyCertificate: 'ART-DEMO-0001', policyEligibility: '特长生资格已核验', profileCompleted: true,
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
}
],
@@ -50,33 +84,29 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
],
exams: [
{
id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。',
registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z',
examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z',
admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z',
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-06-18T02:00:00.000Z',
subjects: [
{ id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30, fullScore: 150, passRule: 'fixed_score', passValue: 90, passScore: 90 },
{ id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30, fullScore: 150, passRule: 'rank_percent', passValue: 60, passScore: null },
{ id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
{ id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
{ id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30, fullScore: 150, passScore: 90 },
{ id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25, fullScore: 100, passScore: 60 },
{ id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25, fullScore: 100, passScore: 60 }
]
id: examId, code: 'EX-2026-ZK', name: '2026 年海州市初中学业水平考试', description: '演示数据主考试:覆盖成绩发布、特长生和第一轮志愿填报。',
registrationStart: '2026-04-01T00:00:00.000Z', registrationEnd: '2026-04-30T15:59:59.000Z',
examStart: '2026-06-20T01:00:00.000Z', examEnd: '2026-06-22T08:00:00.000Z',
admitDownloadStart: '2026-06-10T00:00:00.000Z', admitDownloadEnd: '2026-06-20T00:45:00.000Z',
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-03-18T02:00:00.000Z',
subjects: mainSubjectDefinitions
},
{
id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。',
registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z',
examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z',
admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z',
location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: []
location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: [
{ id: 'mock_sub_chinese', name: '语文', date: '2026-11-08', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
{ id: 'mock_sub_math', name: '数学', date: '2026-11-08', start: '14:30', end: '16:30', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
{ id: 'mock_sub_english', name: '外语', date: '2026-11-09', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 }
]
}
],
registrations: [
{
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
status: 'approved', paymentStatus: 'paid', paidAt: '2026-07-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default'
id: registrationId, userId: candidateId, examId, subjectIds: mainSubjectIds,
status: 'approved', paymentStatus: 'paid', paidAt: '2026-05-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-04-08T05:18:00.000Z', reviewedAt: '2026-04-18T08:32:00.000Z', reviewNote: '报名审核通过', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', featureScore: 90
}
],
results: [],
@@ -171,15 +201,21 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
]
};
const schoolDefinitions = [
const sourceSchools = [
{ key: 'hz1', id: 'school_hz1', code: 'HZ01', name: '海州市第一中学', districtCode: '320706', districtName: '海州区', address: '学府路 8 号' },
{ key: 'hz3', id: 'school_hz3', code: 'HZ03', name: '海州市第三中学', districtCode: '320703', districtName: '连云区', address: '育才路 16 号' },
{ key: 'hz5', id: 'school_hz5', code: 'HZ05', name: '海州市第五中学', districtCode: '320707', districtName: '赣榆区', address: '青口路 28 号' },
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' }
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' },
{ key: 'hz9', id: 'school_hz9', code: 'HZ09', name: '海州市第九中学', districtCode: '320724', districtName: '灌南县', address: '新安路 39 号' }
];
for (const school of schoolDefinitions) {
const admissionSchools = [
{ key: 'admission_1', id: 'school_admission_1', code: 'AD01', name: '海州市高级中学', address: '江苏省连云港市海州区苍梧路 100 号' },
{ key: 'admission_2', id: 'school_admission_2', code: 'AD02', name: '海州市实验高级中学', address: '江苏省连云港市连云区海棠路 88 号' },
{ key: 'admission_3', id: 'school_admission_3', code: 'AD03', name: '海州市外国语高级中学', address: '江苏省连云港市赣榆区黄海路 66 号' }
];
for (const school of sourceSchools) {
if (!database.schools.some(item => item.id === school.id)) {
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, active: true });
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, isSourceSchool: true, isAdmissionSchool: false, active: true });
}
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
const classId = `class_${school.key}_30${classIndex}`;
@@ -189,14 +225,25 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
}
}
for (const school of schoolDefinitions) {
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
for (const school of admissionSchools) {
database.schools.push({ id: school.id, name: school.name, code: school.code, address: school.address, isSourceSchool: false, isAdmissionSchool: true, active: true });
database.users.push({
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
id: `usr_${school.key}`, username: `${school.key}_admin`, passwordHash: testPasswordHash, role: 'admission_school',
schoolId: school.id, displayName: `${school.name}招生办`, active: true, mustChangePassword: false, createdAt: nowIso()
});
}
for (const school of sourceSchools) {
if (school.id !== 'school_hz1') {
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
database.users.push({
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
});
}
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
const classId = `class_${school.key}_30${classIndex}`;
if (classId === 'class_hz1_302') continue;
database.users.push({
id: `usr_test_class_admin_${school.key}_${classIndex}`, username: `test_class_admin_${school.key}_${classIndex}`,
passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: school.id, classId,
@@ -205,7 +252,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
}
}
for (const school of schoolDefinitions) {
for (const school of sourceSchools) {
let center = database.testCenters.find(item => item.schoolId === school.id);
if (!center) {
center = {
@@ -228,21 +275,20 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
}
}
// 测试库停留在考场编排前:不预置编排计划准考证或成绩
// 测试库停留在考场编排前:不预置编排计划准考证;主考试成绩与志愿已完成
const familyNames = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫'];
const givenNames = ['子涵', '梓萱', '宇航', '雨欣', '浩然', '思远', '佳宁', '晨曦', '明轩', '若彤', '嘉诚', '欣怡'];
const subjectSets = [
['sub_chinese', 'sub_math', 'sub_english'],
['sub_chinese', 'sub_math', 'sub_physics', 'sub_chemistry'],
['sub_chinese', 'sub_math', 'sub_history', 'sub_biology'],
['sub_chinese', 'sub_math', 'sub_english', 'sub_physics', 'sub_chemistry'],
['sub_chinese', 'sub_math', 'sub_english', 'sub_history', 'sub_biology']
const specialtyDefinitions = [
{ category: 'sports', type: 'track_field', label: '田径' },
{ category: 'sports', type: 'basketball', label: '篮球' },
{ category: 'arts', type: 'fine_arts', label: '美术' },
{ category: 'arts', type: 'vocal_music', label: '声乐' },
{ category: 'arts', type: 'dance', label: '舞蹈' }
];
const registrationWorkflowId = 'workflow_registration';
for (let index = 0; index < 360; index += 1) {
for (let index = 0; index < mainCandidateCount - 1; index += 1) {
const serial = index + 1001;
const school = schoolDefinitions[index % schoolDefinitions.length];
const classIndex = Math.floor(index / schoolDefinitions.length) % 3 + 1;
const school = sourceSchools[index % sourceSchools.length];
const classIndex = Math.floor(index / sourceSchools.length) % 3 + 1;
const classId = `class_${school.key}_30${classIndex}`;
const gender = index % 2 === 0 ? '男' : '女';
const genderCode = gender === '男' ? 'M' : 'F';
@@ -250,7 +296,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
const profileId = `profile_bulk_${String(index + 1).padStart(4, '0')}`;
const registrationIdBulk = `reg_bulk_${String(index + 1).padStart(4, '0')}`;
const candidateNumber = `2026-${school.code}-${genderCode}-${String(serial).padStart(4, '0')}`;
const createdAt = new Date(Date.UTC(2026, 6, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
const createdAt = new Date(Date.UTC(2026, 3, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
const name = `${familyNames[index % familyNames.length]}${givenNames[Math.floor(index / familyNames.length) % givenNames.length]}${Math.floor(index / 144) + 1}`;
const idNumber = `3207002008${String(index % 12 + 1).padStart(2, '0')}${String(index % 28 + 1).padStart(2, '0')}${String(index + 1).padStart(4, '0')}`;
const phone = `138${String(10000000 + index).padStart(8, '0')}`;
@@ -258,6 +304,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
id: userId, username: candidateNumber, candidateNumber, passwordHash: testPasswordHash, role: 'candidate',
displayName: name, active: true, mustChangePassword: false, createdAt
});
const isSpecialtyCandidate = index < specialtyCandidateCount - 1;
const specialty = isSpecialtyCandidate ? specialtyDefinitions[index % specialtyDefinitions.length] : null;
database.candidateProfiles.push({
id: profileId, userId, name, gender, idNumber, phone, email: `candidate${String(index + 1).padStart(4, '0')}@example.test`,
school: school.name, grade: `高三(${classIndex})班`, schoolId: school.id, classId,
@@ -266,41 +314,77 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
emergencyContact: `${familyNames[index % familyNames.length]}家长`, emergencyPhone: `139${String(10000000 + index).padStart(8, '0')}`,
nativePlace: `江苏${school.districtName}`, birthDate: `2008-${String(index % 12 + 1).padStart(2, '0')}-${String(index % 28 + 1).padStart(2, '0')}`,
ethnicity: index % 19 === 0 ? '回族' : '汉族', postalCode: '222000', guardianName: `${familyNames[index % familyNames.length]}家长`,
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`, profileCompleted: true, status: 'approved',
reviewNote: '批量测试数据:学籍核验通过', reviewedAt: '2026-06-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`,
specialtyCategory: specialty?.category || '', specialtyType: specialty?.type || '', specialtyTypes: specialty ? [specialty.type] : [],
specialtyCertificate: specialty ? `SPECIAL-2026-${String(index + 2).padStart(4, '0')}` : '', policyEligibility: specialty ? `${specialty.label}特长生资格已核验` : '',
profileCompleted: true, status: 'approved', reviewNote: '批量演示数据:学籍核验通过',
reviewedAt: '2026-04-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
});
const scenario = index % 4;
const status = scenario === 0 ? 'pending' : scenario === 1 ? 'rejected' : 'approved';
const paymentStatus = scenario === 3 ? 'paid' : 'unpaid';
const classAdminId = `usr_test_class_admin_${school.key}_${classIndex}`;
const schoolAdmin = `usr_test_school_admin_${school.key}`;
const paymentStatus = index % 2 === 0 ? 'paid' : 'unpaid';
const classAdminId = classId === 'class_hz1_302' ? 'usr_class_admin' : `usr_test_class_admin_${school.key}_${classIndex}`;
database.registrations.push({
id: registrationIdBulk, userId, examId, subjectIds: subjectSets[index % subjectSets.length], status, paymentStatus,
paidAt: paymentStatus === 'paid' ? '2026-07-25T08:30:00.000Z' : null,
id: registrationIdBulk, userId, examId, subjectIds: mainSubjectIds, status: 'approved', paymentStatus,
paidAt: paymentStatus === 'paid' ? '2026-05-18T08:30:00.000Z' : null,
paidBy: paymentStatus === 'paid' ? classAdminId : null,
createdAt, reviewedAt: status === 'pending' ? null : '2026-07-22T08:00:00.000Z',
reviewNote: status === 'rejected' ? '测试场景:报名资料被退回' : status === 'approved' ? '测试场景:报名审核通过' : '',
registrationNumber: candidateNumber, numberRuleId: 'rule_default'
createdAt, reviewedAt: '2026-04-30T08:00:00.000Z', reviewNote: '批量演示数据:报名审核通过',
registrationNumber: candidateNumber, numberRuleId: 'rule_default',
featureScore: specialty ? Number((80 + seededRandom() * 20).toFixed(1)) : 0
});
if (status === 'pending' || status === 'rejected') {
const instanceId = `flow_reg_bulk_${String(index + 1).padStart(4, '0')}`;
database.workflowInstances.push({
id: instanceId, workflowId: registrationWorkflowId, businessType: 'registration_review', businessId: registrationIdBulk,
status, currentStep: 1, assigneeId: status === 'pending' ? schoolAdmin : null, createdAt,
completedAt: status === 'rejected' ? '2026-07-22T08:00:00.000Z' : null
}
const admissionCreatedAt = '2026-07-01T00:00:00.000Z';
database.admissionRecords.push({
id: 'admission_setting_main_2026', kind: 'setting', examId, userId: adminId, schoolId: null, status: 'closed',
payload: {
enabled: true, preferenceStart: '2026-07-01T00:00:00.000Z', preferenceEnd: '2026-07-15T15:59:59.000Z',
maxChoices: 3, maxSubmissions: 1, round: 1, autoPublish: true, progress: '第一轮志愿已全部填报完毕,等待投档'
},
createdAt: admissionCreatedAt, updatedAt: '2026-07-16T00:00:00.000Z'
});
for (const school of admissionSchools) {
database.admissionRecords.push({
id: `admission_plan_${school.key}`, kind: 'plan', examId, userId: `usr_${school.key}`, schoolId: school.id, status: 'approved',
payload: {
categories: [
{ code: 'general', name: '普通生', quota: mainCandidateCount, specialtyCategory: '', specialtyType: '', indicatorAllocations: [] },
{ code: 'sports', name: '体育特长生', quota: specialtyCandidateCount, specialtyCategory: 'sports', specialtyType: '', indicatorAllocations: [] },
{ code: 'arts', name: '艺术特长生', quota: specialtyCandidateCount, specialtyCategory: 'arts', specialtyType: '', indicatorAllocations: [] }
],
note: '演示数据招生计划', submittedBy: `${school.name}招生办`, reviewedBy: '林老师', reviewedAt: admissionCreatedAt, publicVisible: true
},
createdAt: admissionCreatedAt, updatedAt: admissionCreatedAt
});
}
const mainRegistrations = database.registrations.filter(registration => registration.examId === examId);
for (const [candidateIndex, registration] of mainRegistrations.entries()) {
const profile = database.candidateProfiles.find(item => item.userId === registration.userId);
const isSpecialtyCandidate = Boolean(profile?.specialtyCategory);
for (const subject of mainSubjectDefinitions) {
const score = normalScore(subject.fullScore);
database.results.push({
id: `result_main_${String(candidateIndex + 1).padStart(4, '0')}_${subject.id.slice(4)}`,
registrationId: registration.id, subjectId: subject.id, score, grade: scoreGrade(score, subject.fullScore),
published: true, updatedAt: '2026-06-30T08:00:00.000Z', publishedAt: '2026-06-30T08:00:00.000Z'
});
database.workflowActions.push({
id: `flow_action_submit_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: userId, action: 'submit',
note: '提交考试报名', fromAssigneeId: null, toAssigneeId: schoolAdmin, createdAt
});
if (status === 'rejected') {
database.workflowActions.push({
id: `flow_action_reject_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: schoolAdmin, action: 'reject',
note: '测试场景:报名资料被退回', fromAssigneeId: schoolAdmin, toAssigneeId: null, createdAt: '2026-07-22T08:00:00.000Z'
});
}
}
const rotatedAdmissionSchools = admissionSchools.map((_, offset) => admissionSchools[(candidateIndex + offset) % admissionSchools.length]);
database.admissionRecords.push({
id: `preference_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'preference', examId,
userId: registration.userId, schoolId: null, status: 'submitted',
payload: {
round: 1, submissionCount: 1, submittedAt: new Date(Date.UTC(2026, 6, 5 + (candidateIndex % 10), 1 + (candidateIndex % 8), candidateIndex % 60)).toISOString(),
choices: rotatedAdmissionSchools.map(school => ({ schoolId: school.id, categoryCode: 'general', preferenceType: 'general' }))
},
createdAt: admissionCreatedAt, updatedAt: '2026-07-15T08:00:00.000Z'
});
database.admissionRecords.push({
id: `qualification_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'indicator_qualification', examId,
userId: registration.userId, schoolId: profile.schoolId, status: 'confirmed',
payload: { eligible: isSpecialtyCandidate, confirmedAt: '2026-06-28T08:00:00.000Z', note: isSpecialtyCandidate ? '特长资格核验通过' : '普通生' },
createdAt: '2026-06-28T08:00:00.000Z', updatedAt: '2026-06-28T08:00:00.000Z'
});
}
return database;
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import { createSeedDatabase } from '../src/data/seed.mjs';
const state = createSeedDatabase({ nowIso: () => '2026-07-21T00:00:00.000Z', hashPassword: password => `test-${password}` });
const mainExam = state.exams.find(exam => exam.id === 'exam_autumn_2026');
const mainRegistrations = state.registrations.filter(registration => registration.examId === mainExam.id);
const mainRegistrationIds = new Set(mainRegistrations.map(registration => registration.id));
const mainResults = state.results.filter(result => mainRegistrationIds.has(result.registrationId));
const mainPreferences = state.admissionRecords.filter(record => record.kind === 'preference' && record.examId === mainExam.id && Number(record.payload?.round || 1) === 1);
const specialtyProfiles = state.candidateProfiles.filter(profile => profile.specialtyCategory && profile.specialtyType);
const specialtyUserIds = new Set(specialtyProfiles.map(profile => profile.userId));
assert.ok(state.exams.length >= 2, '演示数据至少包含两场考试');
assert.equal(state.schools.filter(school => school.isSourceSchool).length, 5, '演示数据应包含 5 所生源校');
assert.equal(state.schools.filter(school => school.isAdmissionSchool).length, 3, '演示数据应包含 3 所招生校');
assert.deepEqual(Object.fromEntries(mainExam.subjects.map(subject => [subject.name, subject.fullScore])), {
语文: 120, 数学: 120, 外语: 120, 历史: 75, 政治: 75, 物理: 80, 化学: 70, 实验: 20, 信息技术: 10
});
assert.equal(mainRegistrations.length, 1200, '主考试应有 1200 名考生');
assert.ok(mainRegistrations.every(registration => registration.status === 'approved' && registration.subjectIds.length === 9), '主考试报名应全部审核通过并包含 9 科');
assert.equal(mainResults.length, 1200 * 9, '每名主考试考生都应有完整的 9 科成绩');
assert.ok(mainResults.every(result => result.published), '主考试成绩应全部发布');
assert.equal(mainPreferences.length, 1200, '主考试每名考生都应完成第一轮志愿');
assert.ok(mainPreferences.every(record => record.status === 'submitted' && record.payload.submissionCount === 1 && record.payload.choices.length === 3), '第一轮志愿应提交并填满 3 个招生校');
assert.equal(specialtyProfiles.length, 150, '应有 150 名特长生');
assert.ok(mainRegistrations.filter(registration => specialtyUserIds.has(registration.userId)).every(registration => registration.featureScore >= 80 && registration.featureScore <= 100), '特长生特征分应分布在 80-100 分');
assert.deepEqual(new Set(state.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置账号密码应统一为 12345678');
for (const subject of mainExam.subjects) {
const scores = mainResults.filter(result => result.subjectId === subject.id).map(result => result.score);
const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
const standardDeviation = Math.sqrt(scores.reduce((sum, score) => sum + (score - mean) ** 2, 0) / scores.length);
assert.ok(mean > subject.fullScore * 0.66 && mean < subject.fullScore * 0.78, `${subject.name}平均分应符合正态样本预期`);
assert.ok(standardDeviation > subject.fullScore * 0.09 && standardDeviation < subject.fullScore * 0.18, `${subject.name}标准差应符合正态样本预期`);
assert.ok(scores.every(score => score >= 0 && score <= subject.fullScore), `${subject.name}成绩不得超出满分`);
}
console.log('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过');
+2 -1
View File
@@ -83,7 +83,8 @@ process.env.SQLITE_PATH = testDb;
const seededTestDatabase = await createDatabase({
root,
seed: () => {
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
// 全量 1200 人规模由 seed.test.mjs 单独验证;端到端流程使用较小样本控制运行时间。
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword, candidateCount: 360 });
const currentSuperAdmin = state.users.find(user => user.role === 'admin' && user.adminLevel === 'super');
state.users.push({
...currentSuperAdmin,