diff --git a/package.json b/package.json index 3c704b3..ca76326 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/import-test-data.mjs b/scripts/import-test-data.mjs index 688db9a..7d34f30 100644 --- a/scripts/import-test-data.mjs +++ b/scripts/import-test-data.mjs @@ -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'); } diff --git a/src/data/seed.mjs b/src/data/seed.mjs index 9139ec1..4900380 100644 --- a/src/data/seed.mjs +++ b/src/data/seed.mjs @@ -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; diff --git a/tests/seed.test.mjs b/tests/seed.test.mjs new file mode 100644 index 0000000..a702272 --- /dev/null +++ b/tests/seed.test.mjs @@ -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('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过'); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 5e61316..25b417b 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -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,