/** @jest-environment jsdom */ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import PerformanceSurveyPage from './[[...path]]/page' const mockRouterReplace = jest.fn() const mockRouterPush = jest.fn() const mockChoosePerformanceSurveyExportDestination = jest.fn() const mockSavePerformanceSurveyDashboardWorkbook = jest.fn() let mockSearchParams = new URLSearchParams() jest.mock('next/navigation', () => ({ useParams: () => ({ path: [] }), useRouter: () => ({ push: mockRouterPush, replace: mockRouterReplace, }), useSearchParams: () => mockSearchParams, })) jest.mock('next-auth/react', () => ({ useSession: () => ({ data: { user: { id: 'user-admin', name: '管理员', email: 'admin@example.com', }, expires: '2099-01-01T00:00:00.000Z', }, status: 'authenticated', }), })) jest.mock('@/lib/performance-survey-dashboard-export', () => ({ choosePerformanceSurveyExportDestination: (...args: unknown[]) => ( mockChoosePerformanceSurveyExportDestination(...args) ), sanitizePerformanceSurveyExportFileName: (value: string) => `${value}.xlsx`, savePerformanceSurveyDashboardWorkbook: (...args: unknown[]) => ( mockSavePerformanceSurveyDashboardWorkbook(...args) ), })) const templateDefinition = { code: 'visual_review', name: '视觉案例评分', shortName: '视觉评分', description: '视觉案例评分', surveyType: 'visual_case', weight: 1, maxTotal: 60, questionMaxScore: 20, requiredWorkItemCount: 3, workItemImageRequired: true, questions: [{ id: 'quality', title: '作品质量', maxScore: 20 }], scoreBands: [], workflow: { stages: [{ key: 'review', label: '终评', order: 0, reviewers: [{ source: 'direct_manager', label: '直属主管' }], }], scoring: { mode: 'final_reviewer', stageKey: 'review', reviewerSource: 'direct_manager' }, }, } const adminConfig = { month: '2026-07', groups: [{ id: 'group-1', code: 'brand_designer', name: '品牌设计师', sortOrder: 10, isActive: true, status: 'ready', memberCount: 2, rules: [], effectivePlan: { id: 'plan-1', version: 1, effectiveMonth: '2026-07', templateVersionIds: ['version-1'], }, draftPlan: null, }], templates: [{ id: 'template-1', code: 'visual_review', name: '视觉案例评分', description: '视觉案例评分', surveyType: 'visual_case', isActive: true, versions: [{ id: 'version-1', version: 1, status: 'published', effectiveMonth: '2026-07', definition: templateDefinition, }], }], issues: { unmatched: [], conflicts: [], }, } function jsonResponse(data: unknown) { return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data }), text: async () => '', } as unknown as Response } function jsonErrorResponse(error: string, status = 409) { return { ok: false, status, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error }), text: async () => '', } as unknown as Response } describe('考核调研顶层导航保护', () => { const originalFetch = global.fetch beforeEach(() => { mockRouterReplace.mockReset() mockRouterPush.mockReset() mockChoosePerformanceSurveyExportDestination.mockReset() mockChoosePerformanceSurveyExportDestination.mockResolvedValue({ kind: 'download' }) mockSavePerformanceSurveyDashboardWorkbook.mockReset() mockSavePerformanceSurveyDashboardWorkbook.mockResolvedValue(undefined) mockSearchParams = new URLSearchParams('tab=manage&adminWorkspace=positions&adminMonth=2026-07') global.fetch = jest.fn(async (input) => { const url = String(input) if (url.includes('/admin/config-audit-logs')) return jsonResponse({ items: [] }) if (url.includes('/admin/config?')) return jsonResponse(adminConfig) if (url.endsWith('/me')) { return jsonResponse({ user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' }, employee: { id: 'employee-admin', displayName: '管理员' }, permissions: { canCreate: true, isSystemAdmin: true, isDepartmentAdmin: false, isLeadership: false, }, }) } if (url.includes('/departments?purpose=evaluator')) return jsonResponse([]) if (url.endsWith('/surveys')) return jsonResponse([]) return jsonResponse([]) }) as jest.Mock Object.defineProperty(window, 'matchMedia', { configurable: true, value: jest.fn().mockReturnValue({ matches: false }), }) }) afterEach(() => { global.fetch = originalFetch jest.restoreAllMocks() }) test('岗位存在未保存修改时切换个人看板会使用统一弹窗保护', async () => { render() const nameInput = await screen.findByLabelText('岗位名称') const requestedUrls = (global.fetch as jest.Mock).mock.calls.map(([input]) => String(input)) expect(requestedUrls).toContain( '/api/performance-survey/admin/config?month=2026-07&scope=positions', ) expect(requestedUrls.some((url) => url.includes('/departments?purpose=evaluator'))).toBe(false) expect(requestedUrls.some((url) => url.endsWith('/surveys'))).toBe(false) fireEvent.change(nameInput, { target: { value: '品牌设计师(修改中)' } }) expect(await screen.findByText('未保存')).toBeInTheDocument() fireEvent.click(screen.getAllByRole('button', { name: '个人看板' })[0]) const firstDialog = await screen.findByRole('alertdialog', { name: '放弃尚未保存的修改?' }) fireEvent.click(within(firstDialog).getByRole('button', { name: '取消' })) await waitFor(() => expect(screen.queryByRole('alertdialog', { name: '放弃尚未保存的修改?' })).not.toBeInTheDocument()) expect(mockRouterReplace).not.toHaveBeenCalledWith( expect.stringContaining('tab=personal'), expect.anything(), ) fireEvent.click(screen.getAllByRole('button', { name: '个人看板' })[0]) const secondDialog = await screen.findByRole('alertdialog', { name: '放弃尚未保存的修改?' }) fireEvent.click(within(secondDialog).getByRole('button', { name: '放弃并离开' })) await waitFor(() => expect(mockRouterReplace).toHaveBeenCalledWith( '/performance-survey?tab=personal&adminWorkspace=positions', { scroll: false }, )) }) test('发起调研按当前被考核人的人员分配自动关联岗位', async () => { mockSearchParams = new URLSearchParams('tab=manage&adminWorkspace=launch&adminMonth=2026-07') global.fetch = jest.fn(async (input) => { const url = String(input) if (url.endsWith('/me')) { return jsonResponse({ user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' }, employee: { id: 'employee-admin', displayName: '管理员', departmentName: '品牌创意部', positionLevel: 'M1', }, permissions: { canCreate: true, isSystemAdmin: true, isDepartmentAdmin: true, isLeadership: true, }, }) } if (url.includes('/position-groups?month=2026-07')) { return jsonResponse({ month: '2026-07', groups: [{ id: 'group-1', code: 'brand_designer', name: '品牌设计师', status: 'ready', templateCount: 1, }], membership: { status: 'matched', groupCode: 'brand_designer', groupName: '品牌设计师', conflictGroupNames: [], }, }) } if (url.includes('/templates?month=2026-07&positionGroupCode=brand_designer')) { return jsonResponse([{ ...templateDefinition, name: '品牌调性视觉评分(综合主管)', shortName: '视觉评分', templateVersionId: 'version-1', templateOptionKey: 'visual_review:version-1', templateVariantName: '视觉评分(综合主管)', }]) } if (url.includes('/departments?purpose=evaluator')) return jsonResponse([]) if (url.endsWith('/surveys')) return jsonResponse([]) return jsonResponse([]) }) as jest.Mock render() expect(await screen.findByText('品牌设计师')).toBeInTheDocument() expect(screen.queryByRole('combobox', { name: '考核岗位' })).not.toBeInTheDocument() expect(screen.getByText('已按被考核人的人员分配自动关联,发起时无需选择。')).toBeInTheDocument() expect(await screen.findByRole('option', { name: '品牌调性视觉评分(综合主管)' })).toBeInTheDocument() expect(screen.queryByRole('option', { name: '视觉评分(综合主管)' })).not.toBeInTheDocument() await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( '/api/performance-survey/templates?month=2026-07&positionGroupCode=brand_designer', expect.anything(), )) }) test('公共看板可选择全部岗位并请求跨岗位数据', async () => { mockSearchParams = new URLSearchParams('tab=public') const positionGroups = [ { id: 'group-brand', code: 'brand_designer', name: '品牌设计主管', status: 'ready', templateCount: 1, }, { id: 'group-general', code: 'ai_planning_general', name: 'AI策划-综合部', status: 'ready', templateCount: 1, }, ] global.fetch = jest.fn(async (input) => { const url = String(input) if (url.endsWith('/me')) { return jsonResponse({ user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' }, employee: { id: 'employee-admin', displayName: '管理员', departmentName: '品牌设计部', positionLevel: 'M1', }, permissions: { canCreate: true, isSystemAdmin: true, isDepartmentAdmin: true, isLeadership: true, }, }) } if (url.includes('/departments?purpose=dashboard')) return jsonResponse([]) if (url.includes('/dashboard?')) { const allSelected = url.includes('positionGroupCode=all') return jsonResponse({ month: '2026-07', filters: { view: 'public', month: '2026-07', positionGroupCode: allSelected ? 'all' : 'brand_designer', itemPage: 1, }, permissions: { canOpenPublicDetail: true }, positionGroups, positionMembership: { status: 'matched', groupCode: 'brand_designer', groupName: '品牌设计主管', conflictGroupNames: [], }, selectedPositionGroup: allSelected ? null : positionGroups[0], templatePager: { page: 1, totalPages: 1, totalItems: allSelected ? 2 : 1, start: 1, end: allSelected ? 2 : 1, }, templates: allSelected ? [ { code: 'brand_review', name: '品牌调性视觉评分', maxTotal: 120 }, { code: 'general_review', name: '品牌调性视觉评分(无文案)', maxTotal: 120 }, ] : [{ code: 'brand_review', name: '品牌调性视觉评分', maxTotal: 120 }], personal: { rows: [] }, public: { rows: [] }, }) } return jsonResponse([]) }) as jest.Mock render() const positionSelect = await screen.findByRole('combobox', { name: '考核岗位' }) expect(within(positionSelect).getByRole('option', { name: '全部岗位' })).toBeInTheDocument() fireEvent.change(positionSelect, { target: { value: 'all' } }) expect(await screen.findByText('2 个已生效岗位 · 2 个考核项')).toBeInTheDocument() await waitFor(() => expect( (global.fetch as jest.Mock).mock.calls .map(([input]) => String(input)) .some((url) => url.includes('/dashboard?') && url.includes('positionGroupCode=all')), ).toBe(true)) expect( (global.fetch as jest.Mock).mock.calls .map(([input]) => String(input)) .some((url) => url.includes('/departments?purpose=dashboard') && url.includes('positionGroupCode=all')), ).toBe(false) }) test('公共看板按当前岗位和月份选择保存位置并导出全部考核项', async () => { mockSearchParams = new URLSearchParams('tab=public&month=2026-07') const dashboardData = { month: '2026-07', filters: { view: 'public', month: '2026-07', positionGroupCode: 'brand_creative_manager', itemPage: 1, }, permissions: { canOpenPublicDetail: true }, positionGroups: [{ id: 'group-creative-manager', code: 'brand_creative_manager', name: '品牌创意主管', status: 'ready', templateCount: 4, }], positionMembership: { status: 'matched', groupCode: 'brand_creative_manager', groupName: '品牌创意主管', conflictGroupNames: [], }, selectedPositionGroup: { id: 'group-creative-manager', code: 'brand_creative_manager', name: '品牌创意主管', status: 'ready', }, templatePager: { page: 1, totalPages: 1, totalItems: 4, start: 1, end: 4, }, templates: [{ code: 'coordination', name: '跨部门协同', maxTotal: 120 }], personal: { rows: [] }, public: { rows: [{ month: '2026-07', employee: { id: 'employee-boyan', displayName: '伯言', jobNumber: 'JS15040602', departmentName: '品牌创意A部', positionLevel: 'M2', }, cells: { coordination: { score: 114.5, surveyCount: 1, positionGroupCode: 'brand_creative_manager', }, }, }], }, } global.fetch = jest.fn(async (input) => { const url = String(input) if (url.endsWith('/me')) { return jsonResponse({ user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' }, employee: { id: 'employee-admin', displayName: '管理员' }, permissions: { canCreate: true, isSystemAdmin: true, isDepartmentAdmin: true, isLeadership: true }, }) } if (url.includes('/departments?purpose=dashboard')) return jsonResponse([]) if (url.includes('/dashboard/export?')) return jsonResponse(dashboardData) if (url.includes('/dashboard?')) return jsonResponse(dashboardData) return jsonResponse([]) }) as jest.Mock render() fireEvent.click(await screen.findByRole('button', { name: '导出' })) const dialog = await screen.findByRole('dialog', { name: '导出公共看板' }) expect(within(dialog).getByText('品牌创意主管')).toBeInTheDocument() expect(within(dialog).getByText('2026年7月')).toBeInTheDocument() expect(within(dialog).getByText('1 人')).toBeInTheDocument() expect(within(dialog).getByText('4 项')).toBeInTheDocument() fireEvent.click(within(dialog).getByRole('button', { name: '选择保存位置并导出' })) await waitFor(() => expect(mockChoosePerformanceSurveyExportDestination).toHaveBeenCalledWith( '考核调研_品牌创意主管_2026-07.xlsx', )) await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( '/api/performance-survey/dashboard/export?month=2026-07&positionGroupCode=brand_creative_manager', expect.anything(), )) await waitFor(() => expect(mockSavePerformanceSurveyDashboardWorkbook).toHaveBeenCalledWith( dashboardData, '考核调研_品牌创意主管_2026-07.xlsx', { kind: 'download' }, )) expect(await screen.findByRole('status')).toHaveTextContent('请在浏览器下载记录中查看') }) test('我发起的草稿发布失败时展示发布进度和后端原因', async () => { mockSearchParams = new URLSearchParams('tab=personal&workspace=created') let resolvePublish: (response: Response) => void = () => {} const publishResponse = new Promise((resolve) => { resolvePublish = resolve }) global.fetch = jest.fn(async (input, init) => { const url = String(input) if (url.endsWith('/me')) { return jsonResponse({ user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' }, employee: { id: 'employee-admin', displayName: '管理员' }, permissions: { canCreate: true, isSystemAdmin: true, isDepartmentAdmin: true, isLeadership: true, }, }) } if (url.endsWith('/surveys') && (!init?.method || init.method === 'GET')) { return jsonResponse({ summary: { pendingCount: 0, submittedCount: 0, createdCount: 1, openCreatedCount: 0, }, pending: [], submitted: [], created: [{ id: 'survey-draft', title: '2026年7月 · AI作品评审', status: 'draft', departmentName: '综合支持部', dueAt: null, counts: { targets: 1, assignments: 3, responses: 0 }, }], }) } if (url.endsWith('/surveys/survey-draft') && init?.method === 'PATCH') { return publishResponse } return jsonResponse([]) }) as jest.Mock render() const publishButtons = await screen.findAllByRole('button', { name: '发布调研:2026年7月 · AI作品评审' }) expect(screen.getAllByRole('link', { name: '继续编辑草稿:2026年7月 · AI作品评审' })[0]).toHaveAttribute( 'href', '/performance-survey?tab=manage&adminWorkspace=launch&draftId=survey-draft', ) fireEvent.click(publishButtons[0]) expect(await screen.findAllByText('发布中...')).not.toHaveLength(0) resolvePublish(jsonErrorResponse('视觉评分作品尚未上传完整:无邪(0/3)')) expect(await screen.findAllByRole('alert')).not.toHaveLength(0) expect(await screen.findAllByText('发布失败:视觉评分作品尚未上传完整:无邪(0/3)')).not.toHaveLength(0) expect(screen.getAllByRole('link', { name: '继续上传作品' })[0]).toHaveAttribute( 'href', '/performance-survey/survey/survey-draft', ) expect(screen.getAllByRole('button', { name: '发布调研:2026年7月 · AI作品评审' })[0]).toBeEnabled() }) test('继续编辑草稿时恢复原模板和已上传作品', async () => { mockSearchParams = new URLSearchParams('tab=manage&adminWorkspace=launch&adminMonth=2026-07&draftId=survey-draft') global.fetch = jest.fn(async (input) => { const url = String(input) if (url.endsWith('/me')) { return jsonResponse({ user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' }, employee: { id: 'employee-admin', displayName: '管理员', departmentName: '品牌创意部', positionLevel: 'M1', }, permissions: { canCreate: true, isSystemAdmin: true, isDepartmentAdmin: true, isLeadership: true, }, }) } if (url.endsWith('/surveys/survey-draft')) { return jsonResponse({ id: 'survey-draft', title: '2026年7月 · AI作品评审', description: '草稿说明', templateCode: 'visual_review', template: { ...templateDefinition, name: 'AI作品评审', shortName: 'AI作品评审', requiredWorkItemCount: 3, }, status: 'draft', canManage: true, assessmentMonth: '2026-07', dueAt: null, targets: [{ id: 'target-1', targetType: 'employee', targetName: '管理员', targetSnapshot: { id: 'employee-admin', displayName: '管理员', departmentName: '品牌创意部', positionLevel: 'M1', }, canCreateWorkItem: true, canUploadWorkItems: true, workItemUploadRemaining: 2, workItems: [{ id: 'work-item-1', title: '已上传作品', description: '第一版', imageUrls: ['https://s3.jiansun.vip/example.webp'], }], assignments: [{ id: 'assignment-1', evaluatorEmployeeId: 'evaluator-1', evaluatorName: '元泽', evaluatorSnapshot: { id: 'evaluator-1', displayName: '元泽' }, reviewerSource: 'fixed_employee', status: 'pending', }], }], }) } if (url.includes('/position-groups?month=2026-07')) { return jsonResponse({ month: '2026-07', groups: [{ id: 'group-1', code: 'brand_designer', name: '品牌设计师', status: 'ready', templateCount: 1, }], membership: { status: 'matched', groupCode: 'brand_designer', groupName: '品牌设计师', conflictGroupNames: [], }, }) } if (url.includes('/templates?month=2026-07&positionGroupCode=brand_designer')) { return jsonResponse([{ ...templateDefinition, name: 'AI作品评审', shortName: 'AI作品评审', templateVersionId: 'version-1', templateOptionKey: 'visual_review:version-1', requiredWorkItemCount: 3, }]) } if (url.includes('/departments?purpose=evaluator')) return jsonResponse([]) if (url.endsWith('/surveys')) { return jsonResponse({ summary: {}, pending: [], submitted: [], created: [], }) } return jsonResponse([]) }) as jest.Mock render() expect(await screen.findByText('继续编辑未发布草稿')).toBeInTheDocument() expect(screen.getByText('已恢复 1/3 个作品;补齐内容后可直接发布。被考核人与评分流程沿用原草稿。')).toBeInTheDocument() expect(screen.getByText('已上传作品')).toBeInTheDocument() expect(screen.getByLabelText('考评模板')).toBeDisabled() expect(screen.getByLabelText('案例 2 名称')).toBeInTheDocument() expect(screen.getByLabelText('案例 3 名称')).toBeInTheDocument() expect(screen.getByRole('button', { name: '上传剩余作品并发布' })).toBeDisabled() }) test('公共看板仅按后端权限在分数下显示删除按钮并彻底删除单项调研', async () => { mockSearchParams = new URLSearchParams('tab=public&workspace=pending&month=2026-07') let dashboardRequestCount = 0 const deleteRequests: Array<{ url: string; init?: RequestInit }> = [] global.fetch = jest.fn(async (input, init) => { const url = String(input) if (url.endsWith('/me')) { return jsonResponse({ user: { id: 'user-wuxie', name: '无邪', email: 'wuxie@example.com' }, employee: { id: 'employee-wuxie', displayName: '无邪' }, permissions: { canCreate: true, isSystemAdmin: true, isDepartmentAdmin: true, isLeadership: true, }, }) } if (url.includes('/departments?purpose=dashboard')) return jsonResponse([]) if (url.includes('/dashboard/public/assessments/employee-ruolan') && init?.method === 'DELETE') { deleteRequests.push({ url, init }) return jsonResponse({ employeeProfileId: 'employee-ruolan', employeeName: '若兰', templateCode: 'work_coordination', deletedTargetCount: 1, }) } if (url.includes('/dashboard?')) { dashboardRequestCount += 1 return jsonResponse({ month: '2026-07', filters: { view: 'public', month: '2026-07', positionGroupCode: 'ai_planning_creative', itemPage: 1, }, permissions: { canOpenPublicDetail: true, canDeletePublicDashboardAssessment: true, }, positionGroups: [{ id: 'group-creative', code: 'ai_planning_creative', name: 'AI策划-创意部', status: 'ready', templateCount: 1, }], positionMembership: { status: 'matched', groupCode: 'ai_planning_creative', groupName: 'AI策划-创意部', conflictGroupNames: [], }, selectedPositionGroup: { id: 'group-creative', code: 'ai_planning_creative', name: 'AI策划-创意部', status: 'ready', }, templatePager: { page: 1, totalPages: 1, totalItems: 1, start: 1, end: 1, }, templates: [{ code: 'work_coordination', name: '工作配合度', maxTotal: 120, }], personal: { rows: [] }, public: { rows: dashboardRequestCount > 1 ? [] : [{ month: '2026-07', employee: { id: 'employee-ruolan', displayName: '若兰', jobNumber: 'JS18102201', departmentName: '品牌创意部', positionLevel: 'M0', }, cells: { work_coordination: { surveyCount: 1, score: 113, maxTotal: 120, positionGroupCode: 'ai_planning_creative', }, }, }], }, }) } return jsonResponse([]) }) as jest.Mock render() const removeButtons = await screen.findAllByRole('button', { name: '删除若兰的工作配合度调研' }) expect(removeButtons).toHaveLength(2) fireEvent.click(removeButtons[0]) expect(await screen.findByRole('alertdialog')).toHaveTextContent('彻底删除“若兰”的工作配合度?') expect(screen.getByRole('alertdialog')).toHaveTextContent('删除后不可恢复,但可以重新发起该月该项调研') const confirmButton = screen.getByRole('button', { name: '确认彻底删除' }) expect(confirmButton).toBeDisabled() fireEvent.change(screen.getByRole('textbox', { name: /删除原因/ }), { target: { value: '岗位归属错误' }, }) expect(confirmButton).toBeEnabled() fireEvent.click(confirmButton) expect(await screen.findByRole('status')).toHaveTextContent( '已彻底删除若兰在2026年7月的“工作配合度”调研,现在可以重新发起。', ) expect(deleteRequests).toHaveLength(1) expect(JSON.parse(String(deleteRequests[0].init?.body))).toEqual({ month: '2026-07', positionGroupCode: 'ai_planning_creative', templateCode: 'work_coordination', reason: '岗位归属错误', }) expect(dashboardRequestCount).toBeGreaterThan(1) }) })