From 68371922ca8380868675c978f5b0e9fe05215ab1 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 19 May 2026 08:42:03 +0800 Subject: [PATCH] P0-1/P0-2/P1-1: dimensions form + E2E tests + PDF export P0-1 Backend: dimensions column on template entity + validation P0-1 Frontend: dimensions edit UI in TemplateManager P0-2: routeAfterGrading unit tests (10 cases), service spec fix + certificate tests, jest-e2e.json P1-1: proper PDF generation with embedded CJK font via pdf-lib low-level API --- .../assessment/assessment.controller.spec.ts | 4 +- .../src/assessment/assessment.controller.ts | 6 +- .../src/assessment/assessment.service.spec.ts | 101 +++++++++++- .../src/assessment/dto/create-template.dto.ts | 4 + .../entities/assessment-template.entity.ts | 3 + server/src/assessment/graph/builder.spec.ts | 95 ++++++++++++ server/src/assessment/graph/builder.ts | 2 +- .../src/assessment/services/export.service.ts | 99 +++++------- .../src/assessment/services/pdf-generator.ts | 146 ++++++++++++++++++ .../services/template.service.spec.ts | 96 ++++++++++++ .../assessment/services/template.service.ts | 25 +++ server/test/app.e2e-spec.ts | 24 +++ server/test/jest-e2e.json | 12 ++ .../views/AssessmentTemplateManager.tsx | 82 +++++++++- web/components/views/AssessmentView.tsx | 5 +- web/services/assessmentService.ts | 4 +- web/types.ts | 8 + web/utils/translations.ts | 19 +++ 18 files changed, 663 insertions(+), 72 deletions(-) create mode 100644 server/src/assessment/graph/builder.spec.ts create mode 100644 server/src/assessment/services/pdf-generator.ts create mode 100644 server/src/assessment/services/template.service.spec.ts create mode 100644 server/test/app.e2e-spec.ts create mode 100644 server/test/jest-e2e.json diff --git a/server/src/assessment/assessment.controller.spec.ts b/server/src/assessment/assessment.controller.spec.ts index d827f1e..b1bd6c6 100644 --- a/server/src/assessment/assessment.controller.spec.ts +++ b/server/src/assessment/assessment.controller.spec.ts @@ -5,6 +5,7 @@ import { AssessmentService } from './assessment.service'; import { TenantService } from '../tenant/tenant.service'; import { UserService } from '../user/user.service'; import { CombinedAuthGuard } from '../auth/combined-auth.guard'; +import { ExportService } from './services/export.service'; describe('AssessmentController', () => { let controller: AssessmentController; @@ -23,8 +24,9 @@ describe('AssessmentController', () => { controllers: [AssessmentController], providers: [ { provide: AssessmentService, useFactory: mockService }, - { provide: 'UserService', useFactory: mockService }, + { provide: UserService, useFactory: mockService }, { provide: TenantService, useFactory: mockService }, + { provide: ExportService, useFactory: mockService }, { provide: Reflector, useFactory: mockReflector }, { provide: CombinedAuthGuard, useFactory: mockGuard }, ], diff --git a/server/src/assessment/assessment.controller.ts b/server/src/assessment/assessment.controller.ts index 225a566..16ded25 100644 --- a/server/src/assessment/assessment.controller.ts +++ b/server/src/assessment/assessment.controller.ts @@ -271,12 +271,12 @@ export class AssessmentController { } @Get(':id/export/pdf') - @ApiOperation({ summary: 'Export assessment to PDF (text format)' }) + @ApiOperation({ summary: 'Export assessment to PDF' }) async exportPdf(@Param('id') sessionId: string) { const buffer = await this.exportService.exportToPdf(sessionId); return { - filename: `assessment-${sessionId}.txt`, - content: buffer.toString('utf-8'), + filename: `assessment-${sessionId}.pdf`, + buffer: buffer.toString('base64'), }; } } diff --git a/server/src/assessment/assessment.service.spec.ts b/server/src/assessment/assessment.service.spec.ts index 2d074b1..4121a81 100644 --- a/server/src/assessment/assessment.service.spec.ts +++ b/server/src/assessment/assessment.service.spec.ts @@ -1,10 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AssessmentService } from './assessment.service'; -import { AssessmentSession } from './entities/assessment-session.entity'; +import { AssessmentSession, AssessmentStatus } from './entities/assessment-session.entity'; import { AssessmentQuestion } from './entities/assessment-question.entity'; import { AssessmentAnswer } from './entities/assessment-answer.entity'; import { AssessmentCertificate } from './entities/assessment-certificate.entity'; +import { QuestionBank } from './entities/question-bank.entity'; +import { QuestionBankItem } from './entities/question-bank-item.entity'; import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service'; import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service'; import { ModelConfigService } from '../model-config/model-config.service'; @@ -22,16 +24,21 @@ import { NotFoundException } from '@nestjs/common'; describe('AssessmentService', () => { let service: AssessmentService; let sessionRepository: any; + let certificateRepository: any; const mockRepository = () => ({ delete: jest.fn(), find: jest.fn(), findOne: jest.fn(), save: jest.fn(), + create: jest.fn(), }); const mockService = () => ({}); + const regularUser = { id: 'user-1', role: 'user' }; + const adminUser = { id: 'admin-1', role: 'admin' }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -40,6 +47,8 @@ describe('AssessmentService', () => { { provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository }, { provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository }, { provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository }, + { provide: getRepositoryToken(QuestionBank), useFactory: mockRepository }, + { provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository }, { provide: KnowledgeBaseService, useFactory: mockService }, { provide: KnowledgeGroupService, useFactory: mockService }, { provide: ModelConfigService, useFactory: mockService }, @@ -57,6 +66,7 @@ describe('AssessmentService', () => { service = module.get(AssessmentService); sessionRepository = module.get(getRepositoryToken(AssessmentSession)); + certificateRepository = module.get(getRepositoryToken(AssessmentCertificate)); }); it('should be defined', () => { @@ -64,15 +74,96 @@ describe('AssessmentService', () => { }); describe('deleteSession', () => { - it('should delete a session if it exists and belongs to the user', async () => { + it('should delete a session when non-admin user owns it', async () => { sessionRepository.delete.mockResolvedValue({ affected: 1 }); - await expect(service.deleteSession('session-id', 'user-id')).resolves.not.toThrow(); - expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-id' }); + await expect(service.deleteSession('session-id', regularUser)).resolves.not.toThrow(); + expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-1' }); + }); + + it('should delete any session when admin user', async () => { + sessionRepository.delete.mockResolvedValue({ affected: 1 }); + await expect(service.deleteSession('other-session', adminUser)).resolves.not.toThrow(); + expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'other-session' }); }); it('should throw NotFoundException if no session was affected', async () => { sessionRepository.delete.mockResolvedValue({ affected: 0 }); - await expect(service.deleteSession('non-existent', 'user-id')).rejects.toThrow(NotFoundException); + await expect(service.deleteSession('non-existent', regularUser)).rejects.toThrow(NotFoundException); + }); + }); + + describe('generateCertificate', () => { + const completedSession = { + id: 'session-1', + userId: 'user-1', + status: AssessmentStatus.COMPLETED, + finalScore: 85, + templateId: 'template-1', + }; + + it('should throw NotFoundException when session does not exist', async () => { + sessionRepository.findOne.mockResolvedValue(null); + await expect( + service.generateCertificate('no-session', 'user-1', 'tenant-1'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw Error when session is not completed', async () => { + sessionRepository.findOne.mockResolvedValue({ + ...completedSession, + status: AssessmentStatus.IN_PROGRESS, + }); + await expect( + service.generateCertificate('session-1', 'user-1', 'tenant-1'), + ).rejects.toThrow('Session not completed'); + }); + + it('should return existing certificate if already generated (idempotent)', async () => { + const existingCert = { id: 'cert-1', sessionId: 'session-1' }; + sessionRepository.findOne.mockResolvedValue(completedSession); + certificateRepository.findOne.mockResolvedValue(existingCert); + const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1'); + expect(result).toEqual(existingCert); + expect(certificateRepository.create).not.toHaveBeenCalled(); + }); + + it('should create a new certificate with correct level for score >= 90 (Expert)', async () => { + sessionRepository.findOne.mockResolvedValue({ ...completedSession, finalScore: 95 }); + certificateRepository.findOne.mockResolvedValue(null); + certificateRepository.create.mockReturnValue({ id: 'cert-new' }); + certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Expert' }); + + const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1'); + expect(result).toBeDefined(); + expect(certificateRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ level: 'Expert', totalScore: 95 }), + ); + }); + + it('should create a new certificate with Advanced level for score 75-89', async () => { + sessionRepository.findOne.mockResolvedValue(completedSession); + certificateRepository.findOne.mockResolvedValue(null); + certificateRepository.create.mockReturnValue({ id: 'cert-new' }); + certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Advanced' }); + + const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1'); + expect(result).toBeDefined(); + expect(certificateRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ level: 'Advanced', totalScore: 85 }), + ); + }); + + it('should create a new certificate with Novice level for score < 60', async () => { + sessionRepository.findOne.mockResolvedValue({ ...completedSession, finalScore: 45 }); + certificateRepository.findOne.mockResolvedValue(null); + certificateRepository.create.mockReturnValue({ id: 'cert-new' }); + certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Novice' }); + + const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1'); + expect(result).toBeDefined(); + expect(certificateRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ level: 'Novice', totalScore: 45 }), + ); }); }); }); diff --git a/server/src/assessment/dto/create-template.dto.ts b/server/src/assessment/dto/create-template.dto.ts index d9d62f6..710c8f8 100644 --- a/server/src/assessment/dto/create-template.dto.ts +++ b/server/src/assessment/dto/create-template.dto.ts @@ -59,6 +59,10 @@ export class CreateTemplateDto { @IsOptional() linkedGroupIds?: string[]; + @IsArray() + @IsOptional() + dimensions?: Array<{ name: string; label: string; weight: number }>; + @IsObject() @IsOptional() weightConfig?: { diff --git a/server/src/assessment/entities/assessment-template.entity.ts b/server/src/assessment/entities/assessment-template.entity.ts index f8ec84e..e6e78b0 100644 --- a/server/src/assessment/entities/assessment-template.entity.ts +++ b/server/src/assessment/entities/assessment-template.entity.ts @@ -63,6 +63,9 @@ export class AssessmentTemplate { @JoinColumn({ name: 'knowledge_group_id' }) knowledgeGroup: KnowledgeGroup; + @Column({ type: 'simple-json', name: 'dimensions', nullable: true }) + dimensions: Array<{ name: string; label: string; weight: number }>; + @Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean; diff --git a/server/src/assessment/graph/builder.spec.ts b/server/src/assessment/graph/builder.spec.ts new file mode 100644 index 0000000..bcf514b --- /dev/null +++ b/server/src/assessment/graph/builder.spec.ts @@ -0,0 +1,95 @@ +import { routeAfterGrading } from './builder'; + +describe('routeAfterGrading', () => { + it('should route to interviewer when shouldFollowUp is true (overrides all other logic)', () => { + const result = routeAfterGrading({ + shouldFollowUp: true, + currentQuestionIndex: 0, + questionCount: 5, + questions: [], + } as any); + expect(result).toBe('interviewer'); + }); + + it('should route to generator when currentIndex >= questionsLen and currentIndex < targetCount', () => { + const result = routeAfterGrading({ + shouldFollowUp: false, + currentQuestionIndex: 3, + questionCount: 5, + questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }], + } as any); + expect(result).toBe('generator'); + }); + + it('should route to interviewer when currentIndex < questionsLen and currentIndex < targetCount', () => { + const result = routeAfterGrading({ + shouldFollowUp: false, + currentQuestionIndex: 2, + questionCount: 5, + questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }], + } as any); + expect(result).toBe('interviewer'); + }); + + it('should route to analyzer when currentIndex >= targetCount', () => { + const result = routeAfterGrading({ + shouldFollowUp: false, + currentQuestionIndex: 5, + questionCount: 5, + questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }], + } as any); + expect(result).toBe('analyzer'); + }); + + it('should use default targetCount of 5 when questionCount is undefined', () => { + const result = routeAfterGrading({ + shouldFollowUp: false, + currentQuestionIndex: 4, + questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }], + } as any); + expect(result).toBe('generator'); + }); + + it('should use default targetCount of 5 when questionCount is undefined and index 5 routes to analyzer', () => { + const result = routeAfterGrading({ + shouldFollowUp: false, + currentQuestionIndex: 5, + questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }], + } as any); + expect(result).toBe('analyzer'); + }); + + it('should handle undefined questions gracefully (defaults to empty array)', () => { + const result = routeAfterGrading({ + shouldFollowUp: false, + currentQuestionIndex: 0, + questionCount: 5, + } as any); + expect(result).toBe('generator'); + }); + + it('should prevent negative currentQuestionIndex via Math.max(0)', () => { + const result = routeAfterGrading({ + shouldFollowUp: false, + currentQuestionIndex: -1, + questionCount: 5, + questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }], + } as any); + expect(result).toBe('interviewer'); + }); + + it('should handle completely empty state (no fields provided)', () => { + const result = routeAfterGrading({} as any); + expect(result).toBe('generator'); + }); + + it('should route to interviewer at last index before targetCount boundary', () => { + const result = routeAfterGrading({ + shouldFollowUp: false, + currentQuestionIndex: 4, + questionCount: 5, + questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }], + } as any); + expect(result).toBe('interviewer'); + }); +}); diff --git a/server/src/assessment/graph/builder.ts b/server/src/assessment/graph/builder.ts index 06fefca..37a8f57 100644 --- a/server/src/assessment/graph/builder.ts +++ b/server/src/assessment/graph/builder.ts @@ -8,7 +8,7 @@ import { reportAnalyzerNode } from './nodes/analyzer.node'; /** * Conditional routing logic for the Grader node. */ -const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => { +export const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => { const targetCount = state.questionCount || 5; const questionsLen = state.questions?.length || 0; const currentIndex = Math.max(0, state.currentQuestionIndex || 0); diff --git a/server/src/assessment/services/export.service.ts b/server/src/assessment/services/export.service.ts index 000972f..c4971c7 100644 --- a/server/src/assessment/services/export.service.ts +++ b/server/src/assessment/services/export.service.ts @@ -6,6 +6,7 @@ import { AssessmentSession } from '../entities/assessment-session.entity'; import { AssessmentQuestion } from '../entities/assessment-question.entity'; import { AssessmentAnswer } from '../entities/assessment-answer.entity'; import { AssessmentCertificate } from '../entities/assessment-certificate.entity'; +import { generateAssessmentPdf } from './pdf-generator'; @Injectable() export class ExportService { @@ -155,73 +156,55 @@ export class ExportService { where: { questionId: In(questions.map((q) => q.id)) }, }); - const content = this.generatePdfContent(session, questions, answers, certificate); - return Buffer.from(content, 'utf-8'); - } - - private generatePdfContent( - session: AssessmentSession, - questions: AssessmentQuestion[], - answers: AssessmentAnswer[], - certificate: AssessmentCertificate | null, - ): string { - const lines: string[] = []; - - lines.push('='.repeat(60)); - lines.push(' 人才评估报告'); - lines.push('='.repeat(60)); - lines.push(''); - - lines.push(`评估ID: ${session.id}`); - lines.push(`用户: ${session.user?.displayName || session.user?.username || session.userId}`); - lines.push(`状态: ${session.status === 'COMPLETED' ? '已完成' : '进行中'}`); - lines.push(`最终分数: ${session.finalScore || '-'}`); - lines.push(`评估模板: ${session.template?.name || session.templateJson?.name || '-'}`); - lines.push(`评估时间: ${session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'}`); - lines.push(''); - - if (certificate) { - lines.push('-'.repeat(60)); - lines.push('证书信息'); - lines.push('-'.repeat(60)); - lines.push(`等级: ${certificate.level}`); - lines.push(`总分: ${certificate.totalScore}`); - lines.push(`是否通过: ${certificate.passed ? '是' : '否'}`); - lines.push(`颁发时间: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`); - lines.push(''); - } - - lines.push('-'.repeat(60)); - lines.push('题目详情'); - lines.push('-'.repeat(60)); - const answerMap = new Map(answers.map((a) => [a.questionId, a])); + const detailLines: string[] = []; for (let i = 0; i < questions.length; i++) { const q = questions[i]; const a = answerMap.get(q.id); - lines.push(''); - lines.push(`第${i + 1}题:`); - lines.push(` 题目: ${q.questionText || '-'}`); - lines.push(` 用户回答: ${a?.userAnswer || '-'}`); - lines.push(` 得分: ${a?.score ?? '-'}`); - lines.push(` 反馈: ${a?.feedback || '-'}`); - lines.push(` 追问: ${a?.isFollowUp ? '是' : '否'}`); + detailLines.push(`Q${i + 1}: ${q.questionText || '-'}`); + detailLines.push(` A: ${a?.userAnswer || '-'}`); + detailLines.push(` Score: ${a?.score ?? '-'}`); + detailLines.push(` Feedback: ${a?.feedback || '-'}`); } - if (session.finalReport) { - lines.push(''); - lines.push('-'.repeat(60)); - lines.push('综合评估报告'); - lines.push('-'.repeat(60)); - lines.push(session.finalReport); + const certificateLines: string[] = []; + if (certificate) { + certificateLines.push(`Level: ${certificate.level}`); + certificateLines.push(`Total Score: ${certificate.totalScore}`); + certificateLines.push(`Passed: ${certificate.passed ? 'Yes' : 'No'}`); + certificateLines.push(`Issued: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`); } - lines.push(''); - lines.push('='.repeat(60)); - lines.push(' 报告结束'); - lines.push('='.repeat(60)); + const userName = session.user?.displayName || session.user?.username || session.userId; - return lines.join('\n'); + return generateAssessmentPdf({ + title: 'Assessment Report', + subtitle: `${userName} — ${new Date(session.createdAt).toLocaleDateString()}`, + sections: [ + { + title: 'Summary', + lines: [ + `Session: ${session.id}`, + `User: ${userName}`, + `Status: ${session.status}`, + `Score: ${session.finalScore ?? '-'}`, + `Template: ${session.template?.name || session.templateJson?.name || '-'}`, + ], + }, + ...(certificate ? [{ + title: 'Certificate', + lines: certificateLines, + }] : []), + { + title: 'Question Details', + lines: detailLines, + }, + ...(session.finalReport ? [{ + title: 'Final Report', + lines: [session.finalReport], + }] : []), + ], + }); } } \ No newline at end of file diff --git a/server/src/assessment/services/pdf-generator.ts b/server/src/assessment/services/pdf-generator.ts new file mode 100644 index 0000000..4a13cdf --- /dev/null +++ b/server/src/assessment/services/pdf-generator.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { PDFDocument, rgb } from 'pdf-lib'; + +const FONT_SEARCH_PATHS = [ + path.join(__dirname, '..', '..', '..', 'assets', 'fonts', 'NotoSansSC-VF.ttf'), + 'C:/Windows/Fonts/NotoSansSC-VF.ttf', + 'C:/Windows/Fonts/msyh.ttc', + 'C:/Windows/Fonts/simsun.ttc', + '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', + '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc', +]; + +let cachedFontBytes: Buffer | null = null; + +function findFont(): Buffer { + if (cachedFontBytes) return cachedFontBytes; + for (const p of FONT_SEARCH_PATHS) { + try { + if (fs.existsSync(p)) { + cachedFontBytes = fs.readFileSync(p); + return cachedFontBytes; + } + } catch { } + } + return Buffer.alloc(0); +} + +interface PdfReportOptions { + title: string; + subtitle?: string; + sections: PdfSection[]; +} + +interface PdfSection { + title: string; + lines: string[]; +} + +function textToHex(str: string): string { + let hex = ''; + for (let i = 0; i < str.length; i++) { + hex += str.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0'); + } + return hex; +} + +export async function generateAssessmentPdf(options: PdfReportOptions): Promise { + const doc = await PDFDocument.create(); + const page = doc.addPage([595.28, 841.89]); + const ctx = doc.context; + + const fontBytes = findFont(); + if (fontBytes.length === 0) { + throw new Error('No CJK font found. Install Noto Sans SC or similar.'); + } + + const fontProgRef = ctx.nextRef(); + ctx.assign(fontProgRef, ctx.flateStream(fontBytes)); + + const fontDescRef = ctx.nextRef(); + ctx.assign(fontDescRef, ctx.obj({ + Type: 'FontDescriptor', + FontName: 'CJKFont', + Flags: 4, + FontBBox: [0, -300, 1000, 1000], + ItalicAngle: 0, + Ascent: 900, + Descent: -200, + CapHeight: 800, + StemV: 80, + FontFile2: fontProgRef, + })); + + const cidFontRef = ctx.nextRef(); + ctx.assign(cidFontRef, ctx.obj({ + Type: 'Font', + Subtype: 'CIDFontType2', + BaseFont: 'CJKFont', + CIDSystemInfo: ctx.obj({ + Registry: 'Adobe', + Ordering: 'Identity', + Supplement: 0, + }), + FontDescriptor: fontDescRef, + W: [0, [500]], + })); + + const fontRef = ctx.nextRef(); + ctx.assign(fontRef, ctx.obj({ + Type: 'Font', + Subtype: 'Type0', + BaseFont: 'CJKFont', + Encoding: 'Identity-H', + DescendantFonts: [cidFontRef], + })); + + const fontKey = page.node.newFontDictionaryKey('F1'); + page.node.setFontDictionary(fontKey, fontRef); + + let contentOps = ''; + let y = 800; + const margin = 50; + const pageWidth = 595.28; + + function addLine(text: string, size: number, bold: boolean = false) { + const hex = textToHex(text); + contentOps += `BT\n/F1 ${size} Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`; + y -= size * 1.5; + } + + function addSeparator() { + let hex = ''; + for (let i = 0; i < 55; i++) hex += '002D'; + contentOps += `BT\n/F1 8 Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`; + y -= 12; + } + + addLine(options.title, 22); + y -= 4; + if (options.subtitle) { + addLine(options.subtitle, 10); + y -= 4; + } + addSeparator(); + + for (const section of options.sections) { + if (y < 60) break; + addLine(section.title, 12); + for (const line of section.lines) { + if (y < 40) break; + addLine(line, 10); + } + y -= 6; + } + + addSeparator(); + addLine('--- End of Report ---', 9); + + const contentObj = ctx.flateStream(contentOps); + const contentRef = ctx.nextRef(); + ctx.assign(contentRef, contentObj); + page.node.addContentStream(contentRef); + + return Buffer.from(await doc.save()); +} diff --git a/server/src/assessment/services/template.service.spec.ts b/server/src/assessment/services/template.service.spec.ts new file mode 100644 index 0000000..065ebae --- /dev/null +++ b/server/src/assessment/services/template.service.spec.ts @@ -0,0 +1,96 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { TemplateService } from './template.service'; +import { AssessmentTemplate } from '../entities/assessment-template.entity'; +import { TenantService } from '../../tenant/tenant.service'; + +describe('TemplateService', () => { + let service: TemplateService; + let repo: any; + + const mockTenantService = () => ({ + canAccessTenant: jest.fn().mockResolvedValue(true), + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TemplateService, + { + provide: getRepositoryToken(AssessmentTemplate), + useFactory: () => ({ + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + }), + }, + { provide: TenantService, useFactory: mockTenantService }, + ], + }).compile(); + + service = module.get(TemplateService); + repo = module.get(getRepositoryToken(AssessmentTemplate)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should throw BadRequestException when linkedGroupIds is empty', async () => { + const dto = { name: 'Test', linkedGroupIds: [] }; + await expect( + service.create(dto as any, 'user-id', 'tenant-id'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when dimensions is empty array', async () => { + const dto = { name: 'Test', linkedGroupIds: ['g-1'], dimensions: [] }; + await expect( + service.create(dto as any, 'user-id', 'tenant-id'), + ).rejects.toThrow(BadRequestException); + }); + + it('should create template with valid data', async () => { + const dto = { + name: 'Valid Template', + linkedGroupIds: ['g-1'], + dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 0.5 }], + }; + repo.create.mockReturnValue({ id: 'new-id', ...dto }); + repo.save.mockResolvedValue({ id: 'new-id', ...dto }); + + const result = await service.create(dto as any, 'user-id', 'tenant-id'); + expect(result).toBeDefined(); + expect(result.id).toBe('new-id'); + }); + }); + + describe('update', () => { + it('should throw BadRequestException when clearing linkedGroupIds', async () => { + repo.findOne.mockResolvedValue({ + id: 'tpl-1', name: 'Existing', + linkedGroupIds: ['g-1'], + dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }], + }); + + await expect( + service.update('tpl-1', { linkedGroupIds: [] } as any, 'user-id', 'tenant-id'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when clearing dimensions', async () => { + repo.findOne.mockResolvedValue({ + id: 'tpl-1', name: 'Existing', + linkedGroupIds: ['g-1'], + dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }], + }); + + await expect( + service.update('tpl-1', { dimensions: [] } as any, 'user-id', 'tenant-id'), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/server/src/assessment/services/template.service.ts b/server/src/assessment/services/template.service.ts index c9e54e6..05bcedc 100644 --- a/server/src/assessment/services/template.service.ts +++ b/server/src/assessment/services/template.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ForbiddenException, + BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -18,11 +19,33 @@ export class TemplateService { private readonly tenantService: TenantService, ) {} + private validateRequiredFields(data: { + linkedGroupIds?: string[]; + dimensions?: Array<{ name: string; label?: string; weight?: number }>; + }) { + if (data.linkedGroupIds !== undefined) { + if (!Array.isArray(data.linkedGroupIds) || data.linkedGroupIds.length === 0) { + throw new BadRequestException('At least one knowledge group must be linked'); + } + } + if (data.dimensions !== undefined) { + if (!Array.isArray(data.dimensions) || data.dimensions.length === 0) { + throw new BadRequestException('At least one dimension must be defined'); + } + for (const d of data.dimensions) { + if (!d.name) { + throw new BadRequestException('Each dimension must have a name'); + } + } + } + } + async create( createDto: CreateTemplateDto, userId: string, tenantId: string, ): Promise { + this.validateRequiredFields(createDto); const { ...data } = createDto; const template = this.templateRepository.create({ ...data, @@ -76,6 +99,8 @@ export class TemplateService { tenantId: string, ): Promise { const template = await this.findOne(id, userId, tenantId); + const merged = { ...template, ...updateDto }; + this.validateRequiredFields(merged); Object.assign(template, updateDto); return this.templateRepository.save(template); } diff --git a/server/test/app.e2e-spec.ts b/server/test/app.e2e-spec.ts new file mode 100644 index 0000000..371b792 --- /dev/null +++ b/server/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { AppModule } from '../src/app.module'; + +describe('App (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('should be defined', () => { + expect(app).toBeDefined(); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/server/test/jest-e2e.json b/server/test/jest-e2e.json new file mode 100644 index 0000000..11e9b9a --- /dev/null +++ b/server/test/jest-e2e.json @@ -0,0 +1,12 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@/(.*)$": "/../src/$1" + } +} diff --git a/web/components/views/AssessmentTemplateManager.tsx b/web/components/views/AssessmentTemplateManager.tsx index 6d5a1b1..76998ae 100644 --- a/web/components/views/AssessmentTemplateManager.tsx +++ b/web/components/views/AssessmentTemplateManager.tsx @@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext'; import { useConfirm } from '../../contexts/ConfirmContext'; import { templateService } from '../../services/templateService'; import { knowledgeGroupService } from '../../services/knowledgeGroupService'; -import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup } from '../../types'; +import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup, AssessmentDimension } from '../../types'; export const AssessmentTemplateManager: React.FC = () => { const { t } = useLanguage(); @@ -31,6 +31,7 @@ export const AssessmentTemplateManager: React.FC = () => { knowledgeGroupId: '', }); const [copiedId, setCopiedId] = useState(null); + const [dimensions, setDimensions] = useState([]); const fetchTemplates = async () => { setIsLoading(true); @@ -73,6 +74,7 @@ export const AssessmentTemplateManager: React.FC = () => { style: template.style || 'Professional', knowledgeGroupId: template.knowledgeGroupId || '', }); + setDimensions(template.dimensions || []); } else { setEditingTemplate(null); setFormData({ @@ -84,6 +86,7 @@ export const AssessmentTemplateManager: React.FC = () => { style: 'Professional', knowledgeGroupId: '', }); + setDimensions([]); } setShowModal(true); }; @@ -111,6 +114,7 @@ export const AssessmentTemplateManager: React.FC = () => { difficultyDistribution: diffDist, style: formData.style, knowledgeGroupId: formData.knowledgeGroupId || undefined, + dimensions: dimensions.length > 0 ? dimensions : undefined, }; if (editingTemplate) { @@ -141,6 +145,20 @@ export const AssessmentTemplateManager: React.FC = () => { } }; + const handleDimensionChange = (index: number, field: 'name' | 'label' | 'weight', value: string | number) => { + const updated = [...dimensions]; + updated[index] = { ...updated[index], [field]: value }; + setDimensions(updated); + }; + + const handleAddDimension = () => { + setDimensions([...dimensions, { name: '', label: '', weight: 1 }]); + }; + + const handleRemoveDimension = (index: number) => { + setDimensions(dimensions.filter((_, i) => i !== index)); + }; + const handleDelete = async (id: string) => { if (!(await confirm(t('confirmTitle')))) return; try { @@ -255,6 +273,16 @@ export const AssessmentTemplateManager: React.FC = () => { + {Array.isArray(template.dimensions) && template.dimensions.length > 0 && ( +
+ {template.dimensions.map((dim, i) => ( + + {dim.label} ({dim.weight}%) + + ))} +
+ )} +
{Array.isArray(template.keywords) && template.keywords.map((kw, i) => ( @@ -385,6 +413,58 @@ export const AssessmentTemplateManager: React.FC = () => {
+
+ +
+ {dimensions.length === 0 && ( +

{t('mmEmpty')}

+ )} + {dimensions.map((dim, index) => ( +
+ handleDimensionChange(index, 'name', e.target.value)} + placeholder={t('dimensionName')} + /> + handleDimensionChange(index, 'label', e.target.value)} + placeholder={t('dimensionLabel')} + /> + handleDimensionChange(index, 'weight', parseInt(e.target.value) || 0)} + min={0} + max={100} + placeholder={t('dimensionWeight')} + /> + +
+ ))} + +
+
+