feat: add four new student modules (rotation, skill-video, exam-prep, academic) with backend APIs and frontend pages; add exam-prep question history with DB persistence
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import { isErr, isOk } from '@common/result';
|
||||
import { MAX_PAGE_SIZE } from '@common/pagination';
|
||||
|
||||
import {
|
||||
INSUFFICIENT_DATA,
|
||||
ModuleSource,
|
||||
} from '@modules/competency-graph';
|
||||
import { AchievementType } from '@modules/learning-space';
|
||||
import {
|
||||
SkillDefinitionService,
|
||||
createBuiltinSkillDefinitions,
|
||||
} from '@modules/skill-center';
|
||||
import {
|
||||
Action,
|
||||
Purpose,
|
||||
ResourceType,
|
||||
Role,
|
||||
SensitiveFieldCategory,
|
||||
User,
|
||||
} from '@modules/compliance';
|
||||
|
||||
import {
|
||||
IntegrationHarness,
|
||||
buildReferenceItem,
|
||||
makeIntegrationHarness,
|
||||
} from './integration-harness';
|
||||
|
||||
/**
|
||||
* 端到端集成:核心不变量贯通触达(任务 14;确认 Property 1-8 的核心不变量在装配后成立)。
|
||||
*
|
||||
* 说明:Property 1-8 的完整属性测试分散在各模块的 `*.property.spec.ts` /
|
||||
* `*.spec.ts` 中(fast-check 驱动),由全量测试套件统一运行。本文件不重复属性测试,
|
||||
* 而是以集成装配的真实服务对每个 Property 所在领域做一次"核心不变量"冒烟断言,
|
||||
* 确认底座装配正确、跨模块共享时各不变量仍然成立。
|
||||
*/
|
||||
describe('集成:核心不变量贯通触达(Property 1-8 冒烟确认)', () => {
|
||||
let h: IntegrationHarness;
|
||||
const studentId = 'stu-invariant-1';
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await makeIntegrationHarness();
|
||||
});
|
||||
|
||||
it('Property 1(技能定义序列化往返一致性):内置技能定义往返等价', () => {
|
||||
const codec = new SkillDefinitionService();
|
||||
for (const def of createBuiltinSkillDefinitions()) {
|
||||
const roundTripped = codec.deserialize(codec.serialize(def));
|
||||
expect(codec.equivalent(def, roundTripped)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('Property 2(能力标签映射有效性):已定义标签映射成功、未定义标签拒绝且不创建记录', async () => {
|
||||
// 已定义标签 → 成功。
|
||||
const okResult = await h.mappingService.mapData(
|
||||
ModuleSource.LearningSpace,
|
||||
{ studentId, referenceId: 'ref-ok', score: 60 },
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
expect(isOk(okResult)).toBe(true);
|
||||
|
||||
// 未定义标签 → 拒绝、不创建映射记录(需求 14.3)。
|
||||
const before = (await h.mappingService.listMappings(studentId)).length;
|
||||
const badResult = await h.mappingService.mapData(
|
||||
ModuleSource.LearningSpace,
|
||||
{ studentId, referenceId: 'ref-bad' },
|
||||
['tag.does-not-exist'],
|
||||
);
|
||||
expect(isErr(badResult)).toBe(true);
|
||||
const after = (await h.mappingService.listMappings(studentId)).length;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('Property 3(画像维度分值有界性):每个维度为 [0,100] 数值或数据不足标记', async () => {
|
||||
await h.mappingService.mapData(
|
||||
ModuleSource.AIEvaluationEngine,
|
||||
{ studentId, referenceId: 'ref-k', score: 150 }, // 越界 → clamp
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
const profile = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(profile)).toBe(true);
|
||||
if (!isOk(profile)) {
|
||||
return;
|
||||
}
|
||||
for (const dim of profile.value.dimensions) {
|
||||
if (dim.score === INSUFFICIENT_DATA) {
|
||||
expect(dim.traceability).toEqual([]);
|
||||
} else {
|
||||
expect(typeof dim.score).toBe('number');
|
||||
expect(dim.score as number).toBeGreaterThanOrEqual(0);
|
||||
expect(dim.score as number).toBeLessThanOrEqual(100);
|
||||
}
|
||||
}
|
||||
// 越界分值被 clamp 到 100。
|
||||
const knowledge = profile.value.dimensions.find(
|
||||
(d) => d.dimensionName === '知识掌握',
|
||||
)!;
|
||||
expect(knowledge.score).toBe(100);
|
||||
});
|
||||
|
||||
it('Property 4(授权强制不变量):本人完整、非本人无授权脱敏、授权后释放、撤销后失效', async () => {
|
||||
const owner: User = { id: studentId, role: Role.Student };
|
||||
const mentor: User = { id: 'mentor-inv-1', role: Role.Mentor };
|
||||
const profile = {
|
||||
studentId,
|
||||
fields: [
|
||||
{ key: 'dimension.knowledge', value: 80, sensitive: false },
|
||||
{
|
||||
key: 'dimension.clinical',
|
||||
value: 70,
|
||||
sensitive: true,
|
||||
category: SensitiveFieldCategory.MedicalRecord,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 本人 → 完整。
|
||||
const ownerView = await h.consent.resolveProfileView(
|
||||
owner,
|
||||
profile,
|
||||
Purpose.CareerGuidance,
|
||||
);
|
||||
expect(ownerView.fullAccess).toBe(true);
|
||||
expect(ownerView.redactedFieldKeys).toEqual([]);
|
||||
|
||||
// 非本人无授权 → 敏感字段脱敏。
|
||||
const noAuth = await h.consent.resolveProfileView(
|
||||
mentor,
|
||||
profile,
|
||||
Purpose.MentorReview,
|
||||
);
|
||||
expect(noAuth.fullAccess).toBe(false);
|
||||
expect(noAuth.redactedFieldKeys).toContain('dimension.clinical');
|
||||
|
||||
// 授权后 → 敏感字段释放。
|
||||
const now = new Date();
|
||||
const grant = await h.consent.grantConsent({
|
||||
studentId,
|
||||
scope: [SensitiveFieldCategory.MedicalRecord],
|
||||
purpose: Purpose.MentorReview,
|
||||
validFrom: new Date(now.getTime() - 1000),
|
||||
validUntil: new Date(now.getTime() + 60_000),
|
||||
});
|
||||
expect(isOk(grant)).toBe(true);
|
||||
if (!isOk(grant)) {
|
||||
return;
|
||||
}
|
||||
const authed = await h.consent.resolveProfileView(
|
||||
mentor,
|
||||
profile,
|
||||
Purpose.MentorReview,
|
||||
);
|
||||
expect(authed.redactedFieldKeys).not.toContain('dimension.clinical');
|
||||
|
||||
// 撤销后 → 立即按未授权处理(需求 7.8)。
|
||||
await h.consent.revokeConsent(studentId, grant.value.id);
|
||||
const afterRevoke = await h.consent.resolveProfileView(
|
||||
mentor,
|
||||
profile,
|
||||
Purpose.MentorReview,
|
||||
);
|
||||
expect(afterRevoke.redactedFieldKeys).toContain('dimension.clinical');
|
||||
});
|
||||
|
||||
it('Property 5(引用可追溯不变量):引用输出仅含可追溯结论,无法溯源不作为引用输出', async () => {
|
||||
const summary = await h.research.summarize([
|
||||
buildReferenceItem({ id: 'ref-traceable' }),
|
||||
]);
|
||||
expect(isOk(summary)).toBe(true);
|
||||
if (!isOk(summary)) {
|
||||
return;
|
||||
}
|
||||
for (const conclusion of summary.value.citationOutput) {
|
||||
expect(conclusion.verified).toBe(true);
|
||||
expect(conclusion.citations.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
// 未验证结论绝不出现在引用输出中。
|
||||
const unverified = summary.value.conclusions.filter((c) => !c.verified);
|
||||
for (const c of unverified) {
|
||||
expect(summary.value.citationOutput).not.toContain(c);
|
||||
}
|
||||
});
|
||||
|
||||
it('Property 6(权限判定一致性):允许 ⟺ 操作在角色范围内;越权必记录拒绝', async () => {
|
||||
const admin: User = { id: 'admin-inv-1', role: Role.Administrator };
|
||||
const student: User = { id: 'stu-inv-2', role: Role.Student };
|
||||
|
||||
// 管理员可配置技能定义(范围内)。
|
||||
const adminDecision = await h.compliance.checkAccess(
|
||||
admin,
|
||||
{ resourceType: ResourceType.SkillDefinition, identifier: 's-1' },
|
||||
Action.Configure,
|
||||
);
|
||||
expect(adminDecision.allowed).toBe(true);
|
||||
|
||||
// 学生删除审计日志(越权)→ 拒绝并记录。
|
||||
const studentDecision = await h.compliance.checkAccess(
|
||||
student,
|
||||
{ resourceType: ResourceType.AuditLog, identifier: 'log-1' },
|
||||
Action.Delete,
|
||||
);
|
||||
expect(studentDecision.allowed).toBe(false);
|
||||
expect(studentDecision.denialEventId).toBeTruthy();
|
||||
const denials = await h.compliance.listDenialEventsByActor(student.id);
|
||||
expect(denials.length).toBe(1);
|
||||
});
|
||||
|
||||
it('Property 7(错误路径数据保全):非法成果新增被拒且不创建任何记录', async () => {
|
||||
const before = await h.learningSpaceRepo.countAchievements();
|
||||
// 缺少必填元数据(标题为空)→ 拒绝(需求 1.6)。
|
||||
const result = await h.learningSpace.addAchievement(studentId, {
|
||||
type: AchievementType.CourseRecord,
|
||||
title: ' ',
|
||||
occurredAt: new Date('2024-10-01T00:00:00Z'),
|
||||
});
|
||||
expect(isErr(result)).toBe(true);
|
||||
// 数据保全:无任何记录被创建。
|
||||
expect(await h.learningSpaceRepo.countAchievements()).toBe(before);
|
||||
});
|
||||
|
||||
it('Property 8(分页边界不变量):单页结果数量不超过 50', async () => {
|
||||
// 新增 55 条成果。
|
||||
for (let i = 0; i < 55; i += 1) {
|
||||
const result = await h.learningSpace.addAchievement(studentId, {
|
||||
type: AchievementType.CourseRecord,
|
||||
title: `课程记录 ${i + 1}`,
|
||||
occurredAt: new Date('2024-10-01T00:00:00Z'),
|
||||
});
|
||||
expect(isOk(result)).toBe(true);
|
||||
}
|
||||
// 请求 pageSize 超过上界 → 被钳制到 MAX_PAGE_SIZE。
|
||||
const page = await h.learningSpace.listAchievements(
|
||||
studentId,
|
||||
{},
|
||||
{ page: 1, pageSize: 1000 },
|
||||
);
|
||||
expect(page.items.length).toBeLessThanOrEqual(MAX_PAGE_SIZE);
|
||||
expect(page.items.length).toBe(MAX_PAGE_SIZE);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user