gongxiangp2/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx

916 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { changeField, openSelect } from '@/test-utils/select'
/** @jest-environment jsdom */
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { PerformanceSurveyAdminConfig } from './PerformanceSurveyAdminConfig'
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' },
},
} as const
const config = {
month: '2026-07',
groups: [{
id: 'group-1',
code: 'brand_designer',
name: '品牌设计师',
sortOrder: 10,
isActive: true,
status: 'ready',
memberCount: 6,
members: [{
id: 'employee-ruolan',
displayName: '若兰',
departmentName: '品牌创意B部',
positionTitle: '营销策划主管',
}, {
id: 'employee-muyi',
displayName: '木一',
departmentName: '品牌创意A部',
positionTitle: '品牌策划',
}],
rules: [{
id: 'rule-1',
departmentPathPrefix: '集团/品牌中心',
positionTitle: '品牌设计师',
isActive: true,
sortOrder: 0,
}],
effectivePlan: {
id: 'plan-1',
version: 1,
effectiveMonth: '2026-07',
templateVersionIds: ['version-1'],
},
draftPlan: null,
}, {
id: 'group-2',
code: 'ai_designer',
name: 'AI 技术设计师',
sortOrder: 20,
isActive: true,
status: 'pending',
memberCount: 2,
members: [],
rules: [],
effectivePlan: null,
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,
}],
}, {
id: 'template-2',
code: 'course_share',
name: '课程分享',
description: '课程分享',
surveyType: 'item_course',
isActive: true,
versions: [{
id: 'version-2',
version: 1,
status: 'published',
effectiveMonth: '2026-07',
definition: {
...templateDefinition,
code: 'course_share',
name: '课程分享',
shortName: '课程分享',
surveyType: 'item_course',
},
}],
}],
issues: {
unmatched: [{ id: 'employee-1', displayName: '待配置人员', departmentPath: '集团/品牌中心', positionTitle: '设计师' }],
conflicts: [],
},
} as any
function createRequest(configuration = config) {
return jest.fn(async (path: string, options?: RequestInit) => {
if (path.startsWith('/admin/config?')) return configuration
if (path.startsWith('/admin/config-audit-logs')) return { items: [] }
if (path.startsWith('/admin/position-assignments?')) {
return {
items: [{
id: 'employee-1',
employeeId: 'employee-1',
displayName: '张三',
departmentName: '品牌中心',
positionTitle: '设计师',
participation: 'unassigned',
}],
total: 1,
page: 1,
pageSize: 200,
}
}
if (path.startsWith('/employees?purpose=evaluator')) {
return [{ id: 'employee-yuanze', displayName: '元泽', departmentName: '品牌中心' }]
}
if (path.endsWith('/preview?month=2026-07')) {
return {
memberCount: 6,
conflictCount: 0,
draftPlanId: 'plan-1',
draftVersion: 2,
draftUpdatedAt: '2026-07-23T10:00:00.000Z',
}
}
if (path === '/admin/position-groups' && options?.method === 'POST') return { id: 'group-3' }
if (path === '/admin/position-groups/group-1' && options?.method === 'DELETE') {
return { id: 'group-1', action: 'deleted' }
}
if (path === '/admin/templates/template-1' && options?.method === 'DELETE') {
return { id: 'template-1', action: 'deleted' }
}
if (path === '/admin/templates/version-1' && options?.method === 'PATCH') {
return { id: 'version-1' }
}
if (path.endsWith('/copy')) {
return { id: 'copied-version-1', templateId: 'copied-template-1', name: '视觉案例评分(副本)' }
}
return {}
})
}
describe('PerformanceSurveyAdminConfig', () => {
beforeEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: jest.fn().mockReturnValue({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn() }),
})
})
afterEach(() => {
jest.restoreAllMocks()
})
test('概览将岗位设置与发布状态合并展示', async () => {
const request = createRequest()
const onNavigate = jest.fn()
render(<PerformanceSurveyAdminConfig mode="overview" request={request} onNavigate={onNavigate} month="2026-07" />)
expect(screen.getByText('配置加载中...')).toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('heading', { name: '2026-07 配置概览' })).toBeInTheDocument())
expect(screen.getByRole('heading', { name: '本月上线准备' })).toBeInTheDocument()
expect(screen.getByText(/1 人待确认/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '处理人员' }))
expect(onNavigate).toHaveBeenCalledWith('people')
expect(request).toHaveBeenCalledWith('/admin/config?month=2026-07&scope=overview')
expect(request).toHaveBeenCalledWith(expect.stringContaining('/admin/config-audit-logs'))
})
test('不同管理页面只请求当前页面需要的配置范围', async () => {
const request = createRequest()
const view = render(<PerformanceSurveyAdminConfig mode="templates" request={request} month="2026-07" />)
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/config?month=2026-07&scope=templates'))
view.rerender(<PerformanceSurveyAdminConfig mode="people" request={request} month="2026-07" />)
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/config?month=2026-07&scope=people'))
})
test('概览使用汇总数量,不依赖返回全部人员异常明细', async () => {
const request = createRequest({
...config,
issueCounts: { unmatched: 499, conflicts: 0, excluded: 0 },
issues: { unmatched: [], conflicts: [] },
})
render(<PerformanceSurveyAdminConfig mode="overview" request={request} />)
expect(await screen.findByText(/499 人待确认/)).toBeInTheDocument()
})
test('受控月份切换通过外层更新,保持管理工作区月份一致', async () => {
const request = createRequest()
const onMonthChange = jest.fn()
render(
<PerformanceSurveyAdminConfig
mode="overview"
request={request}
month="2026-08"
onMonthChange={onMonthChange}
/>,
)
await waitFor(() => expect(screen.getByRole('heading', { name: '2026-08 配置概览' })).toBeInTheDocument())
expect(screen.getByText(/1\/2 个岗位设置已生效/)).toBeInTheDocument()
changeField(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } })
await waitFor(() => expect(onMonthChange).toHaveBeenCalledWith('2026-09'))
})
test.each([
['positions', '岗位管理'],
['people', '人员分配'],
['templates', '模板管理'],
] as const)('%s 管理页面不显示月份查看入口', async (mode, heading) => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode={mode} request={request} month="2026-07" />)
await waitFor(() => expect(screen.getByRole('heading', { name: heading })).toBeInTheDocument())
expect(screen.queryByLabelText('查看月份')).not.toBeInTheDocument()
expect(request).toHaveBeenCalledWith(`/admin/config?month=2026-07&scope=${mode}`)
})
test('岗位编辑产生 dirty 状态并保护岗位切换', async () => {
const request = createRequest()
const onDirtyChange = jest.fn()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} onDirtyChange={onDirtyChange} />)
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌设计师'))
fireEvent.click(screen.getByRole('button', { name: '修改岗位' }))
changeField(screen.getByLabelText('岗位名称'), { target: { value: '品牌视觉设计师' } })
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ }))
expect(await screen.findByRole('alertdialog', { name: '放弃未保存的修改?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌视觉设计师')
fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ }))
fireEvent.click(await screen.findByRole('button', { name: '放弃修改' }))
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('AI 技术设计师'))
})
test('已发布岗位默认只读,并通过修改岗位进入编辑', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: '修改岗位' })).toBeInTheDocument())
expect(screen.getByLabelText('岗位名称')).toBeDisabled()
expect(screen.getByLabelText('排序')).toBeDisabled()
expect(screen.getByLabelText('添加考核项')).toBeDisabled()
expect(screen.queryByRole('button', { name: '移除' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存草稿' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '发布岗位设置' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '修改岗位' }))
expect(screen.getByLabelText('岗位名称')).toBeEnabled()
expect(screen.getByLabelText('排序')).toBeEnabled()
expect(screen.getByLabelText('添加考核项')).toBeEnabled()
expect(screen.getByRole('button', { name: '移除' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled()
expect(screen.getByRole('button', { name: '发布岗位设置' })).toBeDisabled()
})
test('删除岗位使用统一弹窗收集必填原因并提交', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌设计师'))
fireEvent.click(screen.getByRole('button', { name: '删除岗位' }))
const dialog = await screen.findByRole('alertdialog', { name: '删除岗位“品牌设计师”?' })
const dialogQueries = within(dialog)
expect(dialog).toHaveTextContent('6 人将变为待分配')
expect(dialog).not.toHaveTextContent('历史调研记录会保留')
expect(dialog).not.toHaveTextContent('原因将记录在管理操作日志中')
expect(dialog.parentElement).toHaveClass('backdrop-blur-[2px]')
expect(dialogQueries.getByRole('button', { name: '删除岗位' })).toBeDisabled()
changeField(dialogQueries.getByLabelText(/删除原因/), { target: { value: '岗位合并' } })
fireEvent.click(dialogQueries.getByRole('button', { name: '删除岗位' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/position-groups/group-1',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ mode: 'auto', reason: '岗位合并' }),
}),
))
})
test('岗位页以搜索方式维护草稿考核项,并只展示已选择内容', async () => {
const configuration = {
...config,
groups: config.groups.map((group: any) => group.id === 'group-1'
? {
...group,
draftPlan: {
id: 'plan-draft-1',
version: 2,
effectiveMonth: '2026-08',
templateVersionIds: ['version-2'],
},
}
: group),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument())
expect(screen.getByText('课程分享')).toBeInTheDocument()
expect(screen.queryByText('视觉案例评分')).not.toBeInTheDocument()
changeField(screen.getByLabelText('添加考核项'), { target: { value: '视觉案例' } })
fireEvent.click(screen.getByRole('button', { name: /视觉案例评分/ }))
expect(screen.getByText('视觉案例评分')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
await waitFor(() => {
const call = request.mock.calls.find(([path, options]) => (
path === '/admin/position-groups/group-1' && options?.method === 'PATCH'
))
expect(JSON.parse(String(call?.[1]?.body || '{}'))).toEqual({
name: '品牌设计师',
sortOrder: 10,
templateVersionIds: ['version-2', 'version-1'],
})
})
expect(screen.queryByText('人员匹配规则')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '复制岗位' })).not.toBeInTheDocument()
})
test('岗位模板搜索和已选考核项同步显示模板栏的最新名称', async () => {
const configuration = {
...config,
groups: config.groups.map((group: any) => group.id === 'group-1'
? {
...group,
effectivePlan: {
...group.effectivePlan,
templateVersionIds: [],
},
}
: group),
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: [{
...template.versions[0],
definition: {
...template.versions[0].definition,
name: '品牌创意部门',
},
}],
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '修改岗位' }))
changeField(screen.getByLabelText('添加考核项'), { target: { value: '品牌创意部门' } })
const option = screen.getByRole('button', { name: /品牌创意部门.*已发布/ })
expect(option).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /视觉案例评分.*已发布/ })).not.toBeInTheDocument()
fireEvent.click(option)
expect(screen.getByText('品牌创意部门')).toBeInTheDocument()
})
test('新增岗位使用确定的 collection POST 契约', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /品牌设计师,6 人,已生效/ })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '新增岗位' }))
expect(screen.queryByLabelText('新岗位代码')).not.toBeInTheDocument()
changeField(screen.getByLabelText('新岗位名称'), { target: { value: '新设计岗位' } })
fireEvent.click(screen.getByRole('button', { name: '确认创建' }))
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: '新设计岗位' }),
})))
})
test('岗位人数可展开查看具体人员并可收起', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
const expandButton = (await screen.findAllByRole('button', { name: '展开人员名单' }))[0]
expect(expandButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('若兰')).not.toBeInTheDocument()
fireEvent.click(expandButton)
expect(expandButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('list', { name: '品牌设计师人员名单' })).toBeInTheDocument()
expect(screen.getByText('若兰')).toBeInTheDocument()
expect(screen.getByText('品牌创意B部 · 营销策划主管')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '收起人员名单' }))
expect(screen.queryByText('若兰')).not.toBeInTheDocument()
})
test('人员批量分配提交最终 REST 请求体', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="people" request={request} month="2026-07" />)
await waitFor(() => expect(screen.getAllByText('张三').length).toBeGreaterThan(0))
fireEvent.click(screen.getByLabelText('选择 张三'))
changeField(screen.getByLabelText('批量分配操作'), { target: { value: 'group-1' } })
changeField(screen.getByLabelText('本次调整原因'), { target: { value: '部门异动' } })
fireEvent.click(screen.getByRole('button', { name: '应用分配' }))
await waitFor(() => {
const call = request.mock.calls.find(([path, options]) => (
path === '/admin/position-assignments' && options?.method === 'POST'
))
expect(call).toBeDefined()
expect(JSON.parse(String(call?.[1]?.body || '{}'))).toEqual({
employeeIds: ['employee-1'],
effectiveFromMonth: '2026-07',
effectiveToMonth: null,
assignmentType: 'position',
positionGroupId: 'group-1',
reason: '部门异动',
})
})
})
test('已发布模板通过修改按钮直接更新原版本,并使用统一的删除入口', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '修改模板' }))
changeField(screen.getByLabelText('模板名称'), { target: { value: '新视觉评分' } })
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1',
expect.objectContaining({ method: 'PATCH' }),
))
expect(request.mock.calls.some(([path]) => path === '/admin/templates/version-1/clone')).toBe(false)
expect(screen.getByRole('button', { name: '删除模板' })).toBeInTheDocument()
expect(screen.queryByText('使用场景')).not.toBeInTheDocument()
})
test('新增模板按钮位于模板列表顶部,与岗位管理入口布局一致', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
const createButton = screen.getByRole('button', { name: '新增模板' })
const deleteButton = screen.getByRole('button', { name: '删除模板' })
const templateSidebar = createButton.closest('aside')
expect(templateSidebar).not.toBeNull()
expect(createButton.parentElement).toBe(templateSidebar?.firstElementChild)
expect(within(templateSidebar as HTMLElement).getByRole('button', { name: '新增模板' })).toBe(createButton)
expect(createButton.nextElementSibling).not.toBe(deleteButton)
})
test('已发布模板可通过修改按钮左侧的复制按钮创建独立模板草稿', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
const copyButton = screen.getByRole('button', { name: '复制模板' })
const modifyButton = screen.getByRole('button', { name: '修改模板' })
expect(copyButton.nextElementSibling).toBe(modifyButton)
fireEvent.click(copyButton)
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1/copy',
expect.objectContaining({ method: 'POST', body: '{}' }),
))
})
test('模板列表只展示单一当前配置状态', async () => {
const configuration = {
...config,
templates: [{
...config.templates[0],
versions: [{
...config.templates[0].versions[0],
id: 'version-draft',
version: 2,
status: 'draft',
effectiveMonth: null,
}, config.templates[0].versions[0]],
}, {
...config.templates[1],
versions: [{
...config.templates[1].versions[0],
id: 'version-future',
effectiveMonth: '2026-09',
}],
}, {
...config.templates[1],
id: 'template-draft-only',
code: 'draft_only',
name: '草稿模板',
versions: [{
...config.templates[1].versions[0],
id: 'version-draft-only',
status: 'draft',
effectiveMonth: null,
definition: {
...config.templates[1].versions[0].definition,
code: 'draft_only',
name: '草稿模板',
},
}],
}, {
...config.templates[0],
id: 'template-current',
code: 'current_only',
name: '当前可用模板',
versions: [{
...config.templates[0].versions[0],
id: 'version-current',
definition: {
...config.templates[0].versions[0].definition,
code: 'current_only',
name: '当前可用模板',
},
}],
}],
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
const changedTemplate = await screen.findByRole('button', { name: /视觉案例评分.*可用/ })
expect(within(changedTemplate).getByRole('img', { name: '可用' })).toHaveAttribute('data-status-dot', 'success')
const publishedTemplate = screen.getByRole('button', { name: /课程分享.*可用/ })
expect(within(publishedTemplate).getByRole('img', { name: '可用' })).toHaveAttribute('data-status-dot', 'success')
const draftTemplate = screen.getByRole('button', { name: /草稿模板.*待发布/ })
expect(within(draftTemplate).getByRole('img', { name: '待发布' })).toHaveAttribute('data-status-dot', 'warning')
const availableTemplate = screen.getByRole('button', { name: /当前可用模板.*可用/ })
expect(within(availableTemplate).getByRole('img', { name: '可用' })).toHaveAttribute('data-status-dot', 'success')
expect(screen.queryByText('已发布模板会长期使用;只有评分规则变化时才创建新版本')).not.toBeInTheDocument()
})
test('岗位和模板列表复用同一状态点视觉语义', async () => {
const request = createRequest()
const positions = render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
const positionButton = await screen.findByRole('button', { name: /品牌设计师.*6 人.*已生效/ })
expect(within(positionButton).getByRole('img', { name: '已生效' })).toHaveAttribute('data-status-dot', 'success')
positions.unmount()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
const templateButton = await screen.findByRole('button', { name: /视觉案例评分.*可用/ })
expect(within(templateButton).getByRole('img', { name: '可用' })).toHaveAttribute('data-status-dot', 'success')
})
test('删除模板使用统一弹窗并保留接口错误以便重试', async () => {
const baseRequest = createRequest()
let deleteAttempts = 0
const request = jest.fn(async (path: string, options?: RequestInit) => {
if (path === '/admin/templates/template-1' && options?.method === 'DELETE') {
deleteAttempts += 1
if (deleteAttempts === 1) throw new Error('模板仍被岗位使用,请先移除岗位考核项')
}
return baseRequest(path, options)
})
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '删除模板' }))
const dialog = await screen.findByRole('alertdialog', { name: '删除模板“视觉案例评分”?' })
const dialogQueries = within(dialog)
expect(dialog).not.toHaveAttribute('aria-describedby')
expect(dialog).not.toHaveTextContent('历史调研中的模板和评分数据会继续保留')
expect(dialog).not.toHaveTextContent('如果岗位仍在使用该模板')
expect(dialog).not.toHaveTextContent('原因将记录在管理操作日志中')
expect(dialog.parentElement).toHaveClass('backdrop-blur-[2px]')
changeField(dialogQueries.getByLabelText(/删除原因/), { target: { value: '模板重复' } })
fireEvent.click(dialogQueries.getByRole('button', { name: '删除模板' }))
expect(await dialogQueries.findByRole('alert')).toHaveTextContent('模板仍被岗位使用')
expect(dialogQueries.getByLabelText(/删除原因/)).toHaveValue('模板重复')
fireEvent.click(dialogQueries.getByRole('button', { name: '删除模板' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/template-1',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ mode: 'auto', reason: '模板重复' }),
}),
))
expect(deleteAttempts).toBe(2)
})
test('发布模板不再要求选择生效月份', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({
...version,
status: 'draft',
effectiveMonth: null,
})),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分,待发布/ })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '发布模板' }))
const dialog = await screen.findByRole('dialog', { name: '发布模板“视觉案例评分”?' })
expect(dialog).toHaveTextContent('模板发布后立即可用')
expect(dialog).not.toHaveTextContent('2026-07')
fireEvent.click(within(dialog).getByRole('button', { name: '确认发布' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1/publish',
expect.objectContaining({
method: 'POST',
body: '{}',
}),
))
})
test('模板编辑按基本信息、评分项、评分人设置分步展示', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('navigation', { name: '模板编辑步骤' })).toBeInTheDocument())
expect(screen.getByRole('heading', { name: '基本信息' })).toBeInTheDocument()
expect(screen.getByLabelText('考核内容类型')).toHaveValue('visual_case')
expect(screen.queryByText(/决定调研按员工表现、作品案例或一次具体事项来评分/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '2. 评分项' }))
expect(screen.getByRole('heading', { name: '评分项' })).toBeInTheDocument()
expect(screen.getByLabelText('评分项名称')).toHaveValue('作品质量')
expect(screen.getByLabelText('总分')).toHaveValue(60)
fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' }))
expect(screen.getByRole('heading', { name: '评分人设置' })).toBeInTheDocument()
expect(screen.getByLabelText('最终成绩规则')).toBeInTheDocument()
})
test('评分人设置提供直属主管、元泽和发起时选择任何人', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({ ...version, status: 'draft', effectiveMonth: null })),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: '3. 评分人设置' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' }))
const reviewerSelect = screen.getByLabelText('阶段 1 评分人类型 1')
expect(reviewerSelect).toHaveValue('direct_manager')
expect(within(openSelect(reviewerSelect)).getByRole('option', { name: '直属主管' })).toBeInTheDocument()
expect(within(openSelect(reviewerSelect)).getByRole('option', { name: '元泽' })).toBeInTheDocument()
expect(within(openSelect(reviewerSelect)).getByRole('option', { name: '任何人(发起时选择)' })).toBeInTheDocument()
changeField(reviewerSelect, { target: { value: 'fixed_employee' } })
await waitFor(() => expect(screen.getByLabelText('选择元泽人员档案')).toHaveValue('元泽'))
changeField(reviewerSelect, { target: { value: 'manual_selection' } })
await waitFor(() => expect(reviewerSelect).toHaveValue('manual_selection'))
})
test('元泽人员档案异步返回时不会覆盖等待期间的流程修改', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({ ...version, status: 'draft', effectiveMonth: null })),
}
: template),
}
let resolveEmployees: ((employees: Array<{ id: string; displayName: string }>) => void) | null = null
const employeeRequest = new Promise<Array<{ id: string; displayName: string }>>((resolve) => {
resolveEmployees = resolve
})
const baseRequest = createRequest(configuration)
const request = jest.fn(async (path: string, options?: RequestInit) => {
if (path.startsWith('/employees?purpose=evaluator')) return employeeRequest
return baseRequest(path, options)
})
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: '3. 评分人设置' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' }))
changeField(screen.getByLabelText('阶段 1 评分人类型 1'), { target: { value: 'fixed_employee' } })
changeField(screen.getByLabelText('阶段 1 名称'), { target: { value: '终审修改' } })
await act(async () => {
resolveEmployees?.([{ id: 'employee-yuanze', displayName: '元泽' }])
await employeeRequest
})
await waitFor(() => expect(screen.getByLabelText('选择元泽人员档案')).toHaveValue('元泽'))
expect(screen.getByLabelText('阶段 1 名称')).toHaveValue('终审修改')
})
test('模板草稿可自由增删评分项并显示总分分配状态', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({ ...version, status: 'draft', effectiveMonth: null })),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: '2. 评分项' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '2. 评分项' }))
fireEvent.click(screen.getByRole('button', { name: '删除评分项 1' }))
expect(screen.getByText('还没有评分项,请点击“添加评分项”')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '添加评分项' }))
expect(screen.getByLabelText('评分项名称')).toHaveValue('评分项 1')
expect(screen.getByText('已分配 20 × 3 个作品 = 60 / 60 分')).toBeInTheDocument()
})
test('移动端提供紧凑模板选择器并在选择后直接更新编辑上下文', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
const templateSelect = await screen.findByLabelText('选择模板')
expect(templateSelect).toHaveValue('template-1')
changeField(templateSelect, { target: { value: 'template-2' } })
await waitFor(() => expect(templateSelect).toHaveValue('template-2'))
expect(screen.getByLabelText('模板名称')).toHaveValue('课程分享')
})
test('评分项支持折叠、复制、排序、删除撤销和底部新增', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({ ...version, status: 'draft', effectiveMonth: null })),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
fireEvent.click(await screen.findByRole('button', { name: '2. 评分项' }))
expect(screen.getByRole('button', { name: /评分项 1.*作品质量.*20 分.*完整/ })).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('button', { name: '复制评分项 1' }))
expect(screen.getByRole('button', { name: /评分项 2.*作品质量(副本)/ })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: '上移评分项 2' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '上移评分项 2' }))
expect(screen.getAllByRole('button', { name: /评分项 \d.*作品质量/ })[0]).toHaveAccessibleName(/评分项 1.*作品质量(副本)/)
fireEvent.click(screen.getByRole('button', { name: '删除评分项 1' }))
expect(screen.getByText('已删除“作品质量(副本)”')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '撤销删除' }))
expect(screen.getByRole('button', { name: /评分项 1.*作品质量(副本)/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '添加评分项' })).toBeInTheDocument()
})
test('旧模板缺少单项满分时无需编辑即可显示模板默认分值', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({
...version,
definition: {
...version.definition,
questions: version.definition.questions.map(({ maxScore: _maxScore, ...question }: any) => question),
},
})),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
fireEvent.click(await screen.findByRole('button', { name: '2. 评分项' }))
expect(screen.getByRole('button', { name: /评分项 1.*作品质量.*20 分.*完整/ })).toBeInTheDocument()
expect(screen.getByLabelText('满分')).toHaveValue(20)
expect(screen.getByText('已分配 20 × 3 个作品 = 60 / 60 分')).toBeInTheDocument()
})
test('发布前检查会定位问题,并持续反馈草稿保存状态', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({ ...version, status: 'draft', effectiveMonth: null })),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByLabelText('模板名称')).toHaveValue('视觉案例评分'))
changeField(screen.getByLabelText('模板名称'), { target: { value: '' } })
expect(screen.getByText('请填写模板名称')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '发布模板' })).toBeDisabled()
expect(screen.getByText('未保存')).toBeInTheDocument()
changeField(screen.getByLabelText('模板名称'), { target: { value: '视觉案例评分新版' } })
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1',
expect.objectContaining({ method: 'PATCH' }),
))
expect(await screen.findByText(/草稿已保存 ·/)).toBeInTheDocument()
})
test('固定评分人搜索提供可读状态与键盘可操作结果列表', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({ ...version, status: 'draft', effectiveMonth: null })),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
fireEvent.click(await screen.findByRole('button', { name: '3. 评分人设置' }))
changeField(screen.getByLabelText('阶段 1 评分人类型 1'), { target: { value: 'fixed_employee' } })
const picker = await screen.findByRole('combobox', { name: '选择元泽人员档案' })
await waitFor(() => expect(picker).toHaveValue('元泽'))
changeField(picker, { target: { value: '元' } })
expect(picker).toHaveAttribute('aria-expanded', 'true')
await waitFor(() => expect(screen.getByRole('listbox', { name: '人员搜索结果' })).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('找到 1 人')).toBeInTheDocument())
fireEvent.keyDown(picker, { key: 'Enter' })
expect(picker).toHaveValue('元泽')
expect(picker).toHaveAttribute('aria-expanded', 'false')
})
test('岗位页保存草稿后可直接经过影响确认并发布', async () => {
const configuration = {
...config,
groups: config.groups.map((group: any) => group.id === 'group-1'
? {
...group,
draftPlan: {
id: 'plan-1',
version: 2,
effectiveMonth: '2026-07',
templateVersionIds: ['version-1', 'version-2'],
},
}
: group),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="positions" request={request} month="2026-07" />)
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument())
const publishButton = screen.getByRole('button', { name: '发布岗位设置' })
expect(publishButton).toBeEnabled()
fireEvent.click(publishButton)
const dialog = await screen.findByRole('dialog', { name: '发布“品牌设计师”的岗位设置?' })
expect(dialog).toHaveTextContent('包含 2 个考核项,预计影响 6 人')
fireEvent.click(within(dialog).getByRole('button', { name: '确认发布' }))
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups/group-1/publish', expect.objectContaining({
method: 'POST',
body: JSON.stringify({
draftPlanId: 'plan-1',
draftVersion: 2,
draftUpdatedAt: '2026-07-23T10:00:00.000Z',
}),
})))
})
})