gongxiangp2/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx
2026-07-27 18:32:34 +08:00

474 lines
19 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.

/** @jest-environment jsdom */
import { 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,
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,
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.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.endsWith('/clone')) return { id: 'draft-version-2' }
return {}
})
}
describe('PerformanceSurveyAdminConfig', () => {
beforeEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: jest.fn().mockReturnValue({ matches: false }),
})
})
afterEach(() => {
jest.restoreAllMocks()
})
test('概览展示岗位、人员、模板和岗位考核方案状态', async () => {
const request = createRequest()
const onNavigate = jest.fn()
render(<PerformanceSurveyAdminConfig mode="overview" request={request} onNavigate={onNavigate} />)
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(expect.stringContaining('/admin/config-audit-logs'))
})
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 个在用岗位已有生效方案,.*1 个沿用较早版本/)).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } })
await waitFor(() => expect(onMonthChange).toHaveBeenCalledWith('2026-09'))
})
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.change(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.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).toHaveTextContent('历史调研记录会保留')
expect(dialogQueries.getByRole('button', { name: '删除岗位' })).toBeDisabled()
fireEvent.change(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()
fireEvent.change(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'],
effectiveMonth: '2026-07',
})
})
expect(screen.queryByText('人员匹配规则')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '复制岗位' })).not.toBeInTheDocument()
})
test('新增岗位使用确定的 collection POST 契约', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByText('品牌设计师')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '新增岗位' }))
expect(screen.queryByLabelText('新岗位代码')).not.toBeInTheDocument()
fireEvent.change(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: '新设计岗位', effectiveMonth: '2026-07' }),
})))
})
test('人员批量分配提交最终 REST 请求体', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="people" request={request} />)
await waitFor(() => expect(screen.getAllByText('张三').length).toBeGreaterThan(0))
fireEvent.click(screen.getByLabelText('选择 张三'))
fireEvent.change(screen.getByLabelText('批量分配操作'), { target: { value: 'group-1' } })
fireEvent.change(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.getByText(/v1 · 已发布/)).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '修改模板' }))
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/templates/version-1/clone', expect.objectContaining({ method: 'POST' })))
fireEvent.change(screen.getByLabelText('模板名称'), { target: { value: '新视觉评分' } })
fireEvent.click(screen.getByRole('button', { name: /发布至/ }))
expect(await screen.findByText('请先保存模板草稿,再执行发布')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '删除模板' })).toBeInTheDocument()
expect(screen.queryByText('使用场景')).not.toBeInTheDocument()
})
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.getByText(/v1 · 已发布/)).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '删除模板' }))
const dialog = await screen.findByRole('alertdialog', { name: '删除模板“视觉案例评分”?' })
const dialogQueries = within(dialog)
expect(dialog).toHaveTextContent('历史调研中的模板和评分数据会继续保留')
fireEvent.change(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.getByText(/v1 · 草稿/)).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /发布至 2026-07/ }))
const dialog = await screen.findByRole('dialog', { name: '发布模板“视觉案例评分”?' })
expect(dialog).toHaveTextContent('模板将从 2026-07 起供岗位考核方案使用')
fireEvent.click(within(dialog).getByRole('button', { name: '发布至 2026-07' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1/publish',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ effectiveMonth: '2026-07' }),
}),
))
})
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()
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: '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 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="plans" request={request} />)
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核方案' })).toBeInTheDocument())
expect(screen.getByRole('heading', { name: '待发布的考核项' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: '可用已发布模板' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '发布前检查' }))
await waitFor(() => expect(screen.getByRole('region', { name: '发布影响确认' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '确认发布,2026-07 起生效' }))
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups/group-1/publish', expect.objectContaining({
method: 'POST',
body: JSON.stringify({
effectiveMonth: '2026-07',
draftPlanId: 'plan-1',
draftVersion: 2,
draftUpdatedAt: '2026-07-23T10:00:00.000Z',
}),
})))
})
})