import { registerEmitter } from '../src/realtime/bus.js'; import { existsSync as awaitedSnapshotPath } from 'node:fs'; /** 独立临时数据库上的新增能力验收;不导入 index.ts,不运行自动升级或系统命令。 */ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { prisma } from '../src/lib/db.js'; import { createApp } from '../src/app.js'; import { signToken } from '../src/lib/jwt.js'; import { ensureDefaultGroups } from '../src/lib/permissions.js'; import { runPromotion } from '../src/modules/term/promotion.service.js'; import { exportSnapshot } from '../src/lib/snapshot.js'; import { createAutostartController } from '../src/modules/system/autostart.js'; import { getMaxTermWeeks } from '../src/lib/term.js'; import { setSetting, SETTING_KEYS } from '../src/lib/settings.js'; import { CLASSISLAND_ISLAND_METRICS, normalizeIslandLayouts, defaultIslandLayouts, buildAutoTermWeeks, } from '@classhelper/shared'; if (!process.env.DATABASE_URL?.includes('v14-verification')) throw new Error('只能对专用临时验证数据库运行'); let count = 0; function check(name: string, fn: () => void) { fn(); count++; console.log(`通过 ${count}:${name}`); } const prefix = `v14-${Date.now()}`; const admin = await prisma.user.create({ data: { username: `${prefix}-admin`, name: '临时管理员', passwordHash: '无登录口令,仅本次签发测试令牌', role: 'ADMIN', }, }); const teacher = await prisma.user.create({ data: { username: `${prefix}-teacher`, name: '临时教师', passwordHash: '无登录口令,仅本次签发测试令牌', role: 'TEACHER', }, }); const lead = await prisma.user.create({ data: { username: `${prefix}-lead`, name: '临时级长', passwordHash: '无登录口令,仅本次签发测试令牌', role: 'TEACHER', }, }); const division = await prisma.user.create({ data: { username: `${prefix}-division`, name: '临时部长', passwordHash: '无登录口令,仅本次签发测试令牌', role: 'TEACHER', }, }); const head = await prisma.user.create({ data: { username: `${prefix}-head`, name: '临时班主任', passwordHash: '无登录口令,仅本次签发测试令牌', role: 'TEACHER', }, }); const token = (u: { id: string; username: string; name: string; role: string }) => signToken({ sub: u.id, username: u.username, name: u.name, role: u.role as 'ADMIN' | 'TEACHER', classId: null, }); await ensureDefaultGroups(); await setSetting(SETTING_KEYS.termMaxWeeks, '60'); const cls = await prisma.class.create({ data: { name: '临时高一班', grade: '高一', enrollmentYear: 2090, classIndex: 1, code: `V${Date.now()}A`, teacherId: teacher.id, termWeeks: 60, termStartDate: '2026-09-07', }, }); const other = await prisma.class.create({ data: { name: '临时高二班', grade: '高二', enrollmentYear: 2089, classIndex: 1, code: `V${Date.now()}B`, teacherId: head.id, termWeeks: 60, termStartDate: '2026-09-07', }, }); const third = await prisma.class.create({ data: { name: '临时其他部班', grade: '初一', code: `V${Date.now()}C`, teacherId: head.id }, }); const course = await prisma.course.create({ data: { name: '语文', classId: cls.id, teacherId: teacher.id } }), math = await prisma.course.create({ data: { name: '数学', classId: cls.id, teacherId: teacher.id } }); const outsider = await prisma.course.create({ data: { name: '英语', classId: other.id, teacherId: head.id }, }); for (const [userId, id, scopeValue] of [ [lead.id, 'builtin-grade', '高一'], [division.id, 'builtin-division', '高一,高二'], [head.id, 'builtin-head', 'HEAD'], [teacher.id, 'builtin-teacher', ''], ] as const) { await prisma.permissionGroup.update({ where: { id }, data: { scopeValue } }); await prisma.userPermissionGroup.create({ data: { userId, groupId: id } }); } const server = createServer(createApp()); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const port = (server.address() as { port: number }).port; async function request(path: string, method = 'GET', data?: unknown, auth = token(admin), expected = 200) { const res = await fetch(`http://127.0.0.1:${port}/api${path}`, { method, headers: { authorization: `Bearer ${auth}`, 'content-type': 'application/json' }, ...(data === undefined ? {} : { body: JSON.stringify(data) }), }); const value = await res.json(); assert.equal(res.status, expected, `${method} ${path}:${JSON.stringify(value)}`); return value.data; } try { const points = [ { name: '第一节', startTime: '08:00', endTime: '08:45', timeType: 'LESSON' }, { name: '课间', startTime: '08:45', endTime: '09:00', timeType: 'BREAK' }, { name: '分隔', startTime: '09:00', endTime: '09:00', timeType: 'DIVIDER' }, { name: '行动', startTime: '09:00', endTime: '09:05', timeType: 'ACTION', actionSet: '{"测试":"不执行"}', }, { name: '第二节', startTime: '09:05', endTime: '09:50', timeType: 'LESSON' }, ]; await request('/time-layouts', 'POST', { name: '越权', points }, token(teacher), 403); check('教师不能管理全校时间表', () => {}); const layout = await request('/time-layouts', 'POST', { name: `${prefix}作息`, points }, undefined, 201); check('四种时间点均保存', () => assert.equal(new Set(layout.points.map((p: { timeType: string }) => p.timeType)).size, 4), ); const plan = await request( '/class-plans', 'POST', { classId: cls.id, name: '四周轮换第3周周三', timeLayoutId: layout.id, weekday: 3, weekCountDiv: 3, weekCountDivTotal: 4, weekEnd: 60, }, undefined, 201, ); const entryPoints = layout.points.filter((p: { timeType: string }) => p.timeType === 'LESSON'); await request(`/class-plans/${plan.id}/entries`, 'PUT', { entries: entryPoints.map((p: { id: string }, i: number) => ({ timeLayoutItemId: p.id, courseId: i ? math.id : course.id, })), }); const at = encodeURIComponent('2026-09-23T08:10:00+08:00'); let status = await request(`/schedules/current?classId=${cls.id}&at=${at}`); check('第3周周三唯一解析器上课', () => { assert.equal(status.week, 3); assert.equal(status.current.courseName, '语文'); assert.equal(status.inClass, true); }); const week2 = await request( `/schedules/current?classId=${cls.id}&at=${encodeURIComponent('2026-09-16T08:10:00+08:00')}`, ); check('4周轮换不误命中第2周', () => assert.equal(week2.inClass, false)); await request(`/time-layouts/${layout.id}`, 'PUT', { ...layout, points: [...layout.points].reverse() }); status = await request(`/schedules/current?classId=${cls.id}&at=${at}`); check('排序后显式外键保持语文时段', () => assert.equal(status.current.courseName, '语文')); const beforeFirst = await request( `/schedules/current?classId=${cls.id}&at=${encodeURIComponent('2026-09-23T07:00:00+08:00')}`, ); check('时间点排序后下一节仍按实际时间选择', () => assert.equal(beforeFirst.next.courseName, '语文')); await request( `/class-plans/${plan.id}/entries`, 'PUT', { entries: [{ timeLayoutItemId: entryPoints[0].id, courseId: outsider.id }] }, undefined, 400, ); check('跨班课程拒绝写入', () => {}); await request( '/schedules/swap', 'POST', { classId: cls.id, classPlanId: plan.id, date: '2026-09-23', first: entryPoints[0].id, second: entryPoints[1].id, }, undefined, 201, ); status = await request(`/schedules/current?classId=${cls.id}&at=${at}`); check('换课叠加层生效', () => assert.equal(status.current.courseName, '数学')); const original = await request(`/class-plans/${plan.id}/export`); check('换课不修改原表', () => assert.equal( original.plan.entries.find( (e: { timeLayoutItemId: string }) => e.timeLayoutItemId === entryPoints[0].id, ).courseId, course.id, ), ); await request( '/schedules/scheduled', 'POST', { classId: cls.id, classPlanId: plan.id, date: '2026-09-23' }, undefined, 201, ); status = await request(`/schedules/current?classId=${cls.id}&at=${at}`); check('预定课表优先于临时层', () => assert.equal(status.current.courseName, '语文')); await request( '/schedules/overlay', 'POST', { classId: cls.id, classPlanId: plan.id, date: '2026-09-24' }, undefined, 201, ); check('临时课表可以预定到指定日期', () => {}); for (let i = 0; i < 6; i++) { const student = await prisma.student.create({ data: { studentNo: `${prefix}-${i}`.toUpperCase(), name: `临时学生${i}`, classId: cls.id, status: i === 5 ? 'transferred' : 'active', }, }); await prisma.grade.create({ data: { studentId: student.id, classId: cls.id, courseId: course.id, examName: '临时月考', score: 60 + i, totalScore: 100, }, }); } const classToken = signToken({ sub: cls.id, classId: cls.id, name: cls.name, username: cls.code, role: 'CLASS_DEVICE', classSession: true, }); await prisma.class.update({ where: { id: cls.id }, data: { studentGradeQueryEnabled: false } }); const overview = await request('/grades/overview', 'GET', undefined, classToken); check('关闭个人成绩仍可看集体分析且不含身份/极值', () => { assert.equal(overview.mode, 'collective'); assert(!/studentNo|studentId|studentName|rank|highest|lowest|临时学生/.test(JSON.stringify(overview))); }); let gradeEvent: unknown; registerEmitter((_rooms, event, payload) => { if (event === 'grade:updated') gradeEvent = payload; }); const gradeProbe = await prisma.grade.findFirstOrThrow({ where: { classId: cls.id, student: { status: 'active' } }, }); await request(`/grades/${gradeProbe.id}`, 'PATCH', { score: 65 }); check('个人查询关闭时实时广播也不含任何个人成绩', () => assert.deepEqual(gradeEvent, { classId: cls.id, collective: true }), ); await request('/grades/my', 'GET', undefined, classToken, 403); await request(`/grades/student/${prefix}-0`, 'GET', undefined, classToken, 403); await request(`/grades/ranking?classId=${cls.id}&examName=临时月考`, 'GET', undefined, classToken, 403); check('个人成绩三个入口均不可绕过关闭开关', () => {}); const students = await request(`/classes/${cls.id}/students`, 'GET', undefined, classToken); check('班级端名单不含转出学生', () => assert.equal(students.length, 5)); const roster = await request(`/students?classId=${cls.id}`, 'GET', undefined, token(teacher)); check('教师名单服务端排除转出', () => assert.equal(roster.length, 5)); const transferred = await prisma.student.findUniqueOrThrow({ where: { studentNo: (prefix + '-5').toUpperCase() }, }); const visibleGrades = await request(`/grades?classId=${cls.id}`); assert(!visibleGrades.some((r: { studentId: string }) => r.studentId === transferred.id)); await request( '/calls', 'POST', { classId: cls.id, studentId: transferred.id, message: '仅验证转出过滤' }, undefined, 404, ); const homework = await request( '/homeworks', 'POST', { classId: cls.id, courseId: course.id, title: '转出过滤验收', content: '仅临时库,不向真实班级发布' }, undefined, 201, ); const submissions = await request(`/homeworks/${homework.id}/submissions`, 'GET', undefined, classToken); assert.equal(submissions.total, 5); assert(!submissions.students.some((r: { studentId: string }) => r.studentId === transferred.id)); await prisma.class.update({ where: { id: cls.id }, data: { studentGradeQueryEnabled: true } }); await request(`/grades/student/${transferred.studentNo}`, 'GET', undefined, classToken, 404); await prisma.class.update({ where: { id: cls.id }, data: { studentGradeQueryEnabled: false } }); check('转出学生不可录入叫人、查询成绩或进入作业未交名单', () => {}); await request(`/classes/${other.id}/students`, 'GET', undefined, token(teacher), 403); check('普通教师不可跨班读名单', () => {}); await request(`/grades?classId=${other.id}`, 'GET', undefined, token(lead), 403); await request(`/classes/${cls.id}/students`, 'GET', undefined, token(lead)); check('级长只可读本年级', () => {}); await request(`/classes/${other.id}/students`, 'GET', undefined, token(division)); await request(`/classes/${third.id}/students`, 'GET', undefined, token(division), 403); check('部长只可读配置部范围', () => {}); for (const u of [head, teacher, lead, division]) { await request('/system/settings', 'GET', undefined, token(u), 403); await request('/permissions/groups', 'GET', undefined, token(u), 403); await request('/update/client/fetch', 'POST', { version: '9.9.9' }, token(u), 403); } check('四岗位组均无系统/权限/下发更新权限', () => {}); check('总周数可到60,生成41周不再截成30', () => assert.equal(buildAutoTermWeeks('2026-09-07', 41).length, 41), ); assert.equal(await getMaxTermWeeks(), 60); const dry = await runPromotion(2099, true); check('升级预览不写执行记录、不改变年级', () => assert.equal(dry.status, 'dry-run')); assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: cls.id } })).grade, '高一'); const snapshot = await exportSnapshot(prisma, 'sqlite'); check('快照包含日课表/时间点/升级记录与周次', () => assert( snapshot.data.ClassPlan && snapshot.data.TimeLayoutItem && snapshot.data.PromotionRun && snapshot.data.TermWeek, ), ); check('岛默认固定;非法配置有界回落', () => { assert.equal(defaultIslandLayouts().layout, 'fixed'); const invalid = normalizeIslandLayouts({ layout: 'custom', schemes: [ { id: 'x', rows: [{ nodes: [], main: true }], scale: 999, color: 'url(https://example.com)' }, ], }); assert.equal(invalid.schemes[0]!.scale, CLASSISLAND_ISLAND_METRICS.scaleMax); assert.equal(invalid.schemes[0]!.color, defaultIslandLayouts().schemes[0]!.color); }); const legacy = await request( '/schedules', 'POST', { classId: cls.id, courseId: math.id, dayOfWeek: 1, startTime: '10:00', endTime: '10:45', weekStart: 1, weekEnd: 3, }, undefined, 201, ); check('旧 POST 兼容门面写入日课表', () => assert.equal(legacy.courseId, math.id)); await request(`/schedules/${legacy.id}`, 'PATCH', { weekStart: 2, weekEnd: 4 }); const firstWeek = await request(`/schedules?classId=${cls.id}&week=1`), fourthWeek = await request(`/schedules?classId=${cls.id}&week=4`); check('旧 PATCH 原子调整单节范围,不改变整日其它课程', () => { assert(!firstWeek.some((r: { id: string }) => r.id === legacy.id)); assert(fourthWeek.some((r: { id: string }) => r.id === legacy.id)); }); await request(`/schedules/${legacy.id}`, 'PATCH', { startTime: '11:00', endTime: '11:45' }); const moved = await request(`/schedules?classId=${cls.id}&week=4`); check('旧时间编辑投影新时间点', () => assert.equal(moved.find((r: { id: string }) => r.id === legacy.id).startTime, '11:00'), ); await request(`/schedules/${legacy.id}`, 'DELETE'); check('旧 DELETE 不从遗留 Schedule 复活', () => {}); const payload = { Subjects: { subject: { Name: '语文' } }, TimeLayouts: { layout: { Name: '上游测试', Layouts: [ { StartTime: '08:00:00', EndTime: '08:45:00', TimeType: 0 }, { StartTime: '08:45:00', EndTime: '09:00:00', TimeType: 1 }, { StartTime: '09:00:00', EndTime: '09:00:00', TimeType: 2 }, { StartTime: '09:00:00', EndTime: '09:01:00', TimeType: 3 }, ], }, }, ClassPlans: { plan: { Name: '上游四周', TimeLayoutId: 'layout', TimeRule: { WeekDay: 4, WeekCountDiv: 3, WeekCountDivTotal: 4 }, Classes: [{ SubjectId: 'subject' }], }, }, }; await request('/class-plans/import/profile', 'POST', { classId: cls.id, payload }, undefined, 201); await request('/class-plans/import/profile', 'POST', { classId: cls.id, payload }, undefined, 201); const imported = await request(`/class-plans?classId=${cls.id}`); check('ClassIsland 导入保留4周轮换,merge重复导入不复制课表', () => { const rows = imported.filter((p: { name: string }) => p.name === '上游四周'); assert.equal(rows.length, 1); assert.equal(rows[0].weekCountDivTotal, 4); assert.equal(rows[0].layout.points.length, 4); }); let enabled = false; const os = { platform: 'win32', root: 'F:\\fake-installed-server', exists: () => true, run: (_file: string, args: string[]) => { if (args.includes('InstallDir')) return { ok: true, output: 'InstallDir REG_SZ F:\\fake-installed-server' }; if (args[0] === 'add') { enabled = true; return { ok: true, output: '' }; } if (args[0] === 'delete') { enabled = false; return { ok: true, output: '' }; } return { ok: enabled, output: enabled ? 'ClassHelperServer REG_SZ "F:\\fake-installed-server\\start.cmd"' : '', }; }, }; const autostart = createAutostartController(os); check('安装形态自启动开启/关闭且回读一致(假系统适配器)', () => { assert.equal(autostart.read().supported, true); assert.equal(autostart.write(true).enabled, true); assert.equal(createAutostartController(os).read().enabled, true); assert.equal(autostart.write(false).enabled, false); }); check('非安装形态返回不可用原因', () => { const value = createAutostartController({ ...os, exists: () => false }).read(); assert.equal(value.supported, false); assert(value.detail.length > 10); }); // 此文件开头已强制临时库,实际升级不会触及演示或业务数据。 await prisma.class.create({ data: { name: '临时高三班', grade: '高三', enrollmentYear: 2088, classIndex: 1, code: prefix + 'G', teacherId: head.id, }, }); const [promotion, repeated] = await Promise.all([runPromotion(2099), runPromotion(2099)]); check('临时库并发升级同年幂等且执行前快照存在', () => { assert.equal(promotion.status, 'done'); assert.equal(repeated.snapshotPath, promotion.snapshotPath); assert.equal(awaitedSnapshotPath(promotion.snapshotPath), true); }); const skeleton = await prisma.class.findFirstOrThrow({ where: { enrollmentYear: 2099, grade: '高一' } }); check('新高一骨架不复制班主任或任课授权', () => assert.equal(skeleton.teacherId, null)); assert.equal(await prisma.course.count({ where: { classId: skeleton.id } }), 0); check('临时高三已归档,其余只升一级', () => assert.equal(repeated.classCount, 3)); assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: cls.id } })).grade, '高二'); assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: other.id } })).grade, '高三'); console.log(`=== V1.4 新增能力:${count}/${count} 项通过 ===`); } finally { await new Promise((resolve) => server.close(() => resolve())); await prisma.$disconnect(); }