/** @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)
})
})