416 lines
18 KiB
TypeScript
416 lines
18 KiB
TypeScript
/** @jest-environment jsdom */
|
||
|
||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
import { WorkReviewReviewerWorkspace } from '@/components/work-review/WorkReviewReviewerWorkspace'
|
||
import type { WorkReviewProject } from '@/types/work-review'
|
||
|
||
const project: WorkReviewProject = {
|
||
id: 'project-1',
|
||
title: '测试作品',
|
||
description: null,
|
||
workDate: '2026-08-01',
|
||
status: 'pending_review',
|
||
latestVersion: 1,
|
||
submitter: { id: 'submitter-1', name: '提交人' },
|
||
manager: { id: 'manager-1', name: '评审人' },
|
||
finalReviewer: { id: 'final-1', name: '元泽' },
|
||
createdAt: '2026-08-01T00:00:00.000Z',
|
||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||
versions: [{
|
||
id: 'version-1',
|
||
version: 1,
|
||
submissionNote: null,
|
||
status: 'pending_review',
|
||
submittedAt: '2026-08-01T00:00:00.000Z',
|
||
completedAt: null,
|
||
imageUrls: [],
|
||
imageGroups: [{
|
||
imageId: 'image-1',
|
||
sourceName: '作品长图',
|
||
previewUrl: '/preview.webp',
|
||
previewWidth: 1000,
|
||
previewHeight: 2000,
|
||
width: 1000,
|
||
height: 2000,
|
||
slices: [{ url: '/slice.webp', width: 1000, height: 2000 }],
|
||
}],
|
||
referenceImageUrls: [],
|
||
referenceImageGroups: [],
|
||
reviews: [],
|
||
}],
|
||
permissions: {
|
||
canResubmit: false,
|
||
pendingReviewRoles: ['manager'],
|
||
},
|
||
}
|
||
|
||
const projectWithReference: WorkReviewProject = {
|
||
...project,
|
||
versions: [{
|
||
...project.versions[0],
|
||
referenceImageGroups: [{
|
||
imageId: 'reference-1',
|
||
sourceName: '参考长图',
|
||
previewUrl: '/reference-preview.webp',
|
||
previewWidth: 1000,
|
||
previewHeight: 2000,
|
||
width: 1000,
|
||
height: 2000,
|
||
slices: [{ url: '/reference-slice.webp', width: 1000, height: 2000 }],
|
||
}],
|
||
}],
|
||
}
|
||
|
||
describe('WorkReviewReviewerWorkspace', () => {
|
||
test('switching image type shows only the matching optional reference images', async () => {
|
||
const user = userEvent.setup()
|
||
const group = project.versions[0].imageGroups[0]
|
||
render(<WorkReviewReviewerWorkspace project={{
|
||
...project,
|
||
versions: [{
|
||
...project.versions[0],
|
||
imageGroups: [
|
||
{ ...group, imageId: 'main', sourceName: '主图作品', assetType: 'main' },
|
||
{ ...group, imageId: 'sku', sourceName: 'SKU作品', assetType: 'sku' },
|
||
{ ...group, imageId: 'detail', sourceName: '详情作品', assetType: 'detail' },
|
||
],
|
||
referenceImageGroups: [
|
||
{ ...group, imageId: 'ref-main', sourceName: '主图参考', assetType: 'main' },
|
||
{ ...group, imageId: 'ref-sku', sourceName: 'SKU参考', assetType: 'sku' },
|
||
],
|
||
}],
|
||
}} onSubmitReview={jest.fn()} />)
|
||
expect(screen.getAllByRole('img', { name: '主图参考' })).toHaveLength(2)
|
||
expect(screen.queryByRole('img', { name: 'SKU参考' })).not.toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: /(?:主图|详情|SKU)图片 \d+/ })).not.toBeInTheDocument()
|
||
await user.click(screen.getAllByRole('button', { name: 'SKU图片' })[0])
|
||
expect(screen.getAllByRole('img', { name: 'SKU参考' })).toHaveLength(2)
|
||
expect(screen.queryByRole('img', { name: '主图参考' })).not.toBeInTheDocument()
|
||
await user.click(screen.getAllByRole('button', { name: '详情图片' })[0])
|
||
expect(screen.getAllByText('当前类型未上传参考图片')).toHaveLength(2)
|
||
expect(screen.queryByRole('img', { name: 'SKU参考' })).not.toBeInTheDocument()
|
||
})
|
||
|
||
test('same-type images remain individually selectable without numbered labels', async () => {
|
||
const user = userEvent.setup()
|
||
const group = project.versions[0].imageGroups[0]
|
||
render(<WorkReviewReviewerWorkspace project={{
|
||
...project,
|
||
versions: [{
|
||
...project.versions[0],
|
||
imageGroups: [
|
||
{ ...group, imageId: 'main-1', sourceName: '第一张主图', assetType: 'main' },
|
||
{ ...group, imageId: 'main-2', sourceName: '第二张主图', assetType: 'main' },
|
||
{ ...group, imageId: 'sku-1', sourceName: '第一张SKU', assetType: 'sku' },
|
||
{ ...group, imageId: 'sku-2', sourceName: '第二张SKU', assetType: 'sku' },
|
||
],
|
||
}],
|
||
}} onSubmitReview={jest.fn()} />)
|
||
|
||
expect(screen.getAllByRole('button', { name: '主图图片' })).toHaveLength(4)
|
||
expect(screen.getAllByRole('button', { name: 'SKU图片' })).toHaveLength(4)
|
||
expect(screen.queryByRole('button', { name: /(?:主图|SKU)图片 \d+/ })).not.toBeInTheDocument()
|
||
|
||
for (const sourceName of ['第二张主图', '第二张SKU', '第一张SKU', '第一张主图']) {
|
||
await user.click(screen.getAllByTitle(sourceName)[0])
|
||
expect(screen.getAllByRole('img', { name: sourceName })).toHaveLength(2)
|
||
expect(screen.getAllByTitle(sourceName)[0]).toHaveAttribute('aria-pressed', 'true')
|
||
}
|
||
})
|
||
|
||
test('shows the submitted work description above the image workspace', () => {
|
||
render(
|
||
<WorkReviewReviewerWorkspace
|
||
project={{
|
||
...project,
|
||
description: '用于测试新版视觉主图。\n重点关注标题层级与产品卖点。',
|
||
}}
|
||
onSubmitReview={jest.fn()}
|
||
/>,
|
||
)
|
||
|
||
const description = screen.getByText((_, element) => (
|
||
element?.tagName === 'P'
|
||
&& element.textContent === '用于测试新版视觉主图。\n重点关注标题层级与产品卖点。'
|
||
))
|
||
expect(screen.getByRole('heading', { name: '作品说明' })).toBeInTheDocument()
|
||
expect(description).toHaveClass('whitespace-pre-wrap', 'break-words')
|
||
expect(description.compareDocumentPosition(screen.getAllByText('作品图片')[0]))
|
||
.toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||
})
|
||
|
||
test('keeps mobile box selection under app control and recovers after a cancelled gesture', async () => {
|
||
window.PointerEvent = MouseEvent as typeof PointerEvent
|
||
const user = userEvent.setup()
|
||
render(<WorkReviewReviewerWorkspace project={project} onSubmitReview={jest.fn()} />)
|
||
|
||
await user.click(screen.getAllByRole('button', { name: '+ 添加标注' })[0])
|
||
const canvas = screen.getAllByRole('group', { name: /作品长图,可添加位置批注/ })[0]
|
||
expect(canvas).toHaveClass('touch-none', 'select-none', '[-webkit-touch-callout:none]')
|
||
expect(canvas).not.toHaveClass('touch-pan-y')
|
||
expect(screen.getAllByRole('status')[0]).toHaveTextContent('手机端如需滚动,请先关闭标注工具')
|
||
|
||
jest.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||
x: 0,
|
||
y: 0,
|
||
top: 0,
|
||
left: 0,
|
||
right: 1000,
|
||
bottom: 2000,
|
||
width: 1000,
|
||
height: 2000,
|
||
toJSON: () => ({}),
|
||
})
|
||
|
||
fireEvent.pointerDown(canvas, { pointerId: 1, button: 0, clientX: 200, clientY: 400 })
|
||
fireEvent.pointerMove(canvas, { pointerId: 1, button: 0, clientX: 450, clientY: 800 })
|
||
fireEvent.pointerCancel(canvas, { pointerId: 1 })
|
||
expect(screen.queryByRole('textbox', { name: /修改意见/ })).not.toBeInTheDocument()
|
||
|
||
fireEvent.pointerDown(canvas, { pointerId: 2, button: 0, clientX: 300, clientY: 500 })
|
||
fireEvent.pointerMove(canvas, { pointerId: 2, button: 0, clientX: 600, clientY: 950 })
|
||
fireEvent.pointerUp(canvas, { pointerId: 2, button: 0, clientX: 600, clientY: 950 })
|
||
|
||
expect(screen.getByRole('textbox', { name: /图 1 · 标注 1 修改意见/ })).toBeInTheDocument()
|
||
})
|
||
|
||
test('moves an unfinished annotation, locks it on completion, and submits the moved position', async () => {
|
||
window.PointerEvent = MouseEvent as typeof PointerEvent
|
||
const user = userEvent.setup()
|
||
const onSubmitReview = jest.fn().mockResolvedValue(undefined)
|
||
render(<WorkReviewReviewerWorkspace project={project} onSubmitReview={onSubmitReview} />)
|
||
|
||
expect(screen.getAllByText('已标注 0 个修改点')).toHaveLength(2)
|
||
expect(screen.queryByText(/已标注 0 个修改点;点击/)).not.toBeInTheDocument()
|
||
expect(screen.queryByText('两位评审均提交后,系统会通知发起人')).not.toBeInTheDocument()
|
||
expect(screen.queryByText('左右滑动查看作品与参考')).not.toBeInTheDocument()
|
||
|
||
await user.click(screen.getAllByRole('button', { name: '+ 添加标注' })[0])
|
||
expect(screen.getAllByRole('toolbar', { name: '标注工具' })).toHaveLength(2)
|
||
expect(screen.getAllByRole('button', { name: '矩形框选' })[0]).toHaveAttribute('aria-pressed', 'true')
|
||
await user.click(screen.getAllByRole('radio', { name: '红色' })[0])
|
||
const canvas = screen.getAllByRole('group', { name: /作品长图,可添加位置批注/ })[0]
|
||
jest.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||
x: 0,
|
||
y: 0,
|
||
top: 0,
|
||
left: 0,
|
||
right: 1000,
|
||
bottom: 2000,
|
||
width: 1000,
|
||
height: 2000,
|
||
toJSON: () => ({}),
|
||
})
|
||
fireEvent.pointerDown(canvas, { button: 0, clientX: 250, clientY: 500 })
|
||
fireEvent.pointerMove(canvas, { button: 0, clientX: 500, clientY: 900 })
|
||
fireEvent.pointerUp(canvas, { button: 0, clientX: 500, clientY: 900 })
|
||
|
||
const comment = screen.getByRole('textbox', { name: /图 1 · 标注 1 修改意见/ })
|
||
await user.type(comment, '人物位置向左移动')
|
||
|
||
fireEvent.pointerDown(canvas, { button: 0, clientX: 750, clientY: 1200 })
|
||
fireEvent.pointerMove(canvas, { button: 0, clientX: 850, clientY: 1400 })
|
||
fireEvent.pointerUp(canvas, { button: 0, clientX: 850, clientY: 1400 })
|
||
await user.click(screen.getByRole('button', { name: '完成' }))
|
||
|
||
fireEvent.pointerDown(canvas, { button: 0, clientX: 100, clientY: 200 })
|
||
fireEvent.pointerUp(canvas, { button: 0, clientX: 100, clientY: 200 })
|
||
|
||
expect(screen.getAllByText('已标注 1 个修改点')).toHaveLength(2)
|
||
const commentLabels = screen.getAllByText('人物位置向左移动')
|
||
expect(commentLabels).toHaveLength(2)
|
||
await user.click(screen.getAllByRole('button', { name: '隐藏标注点备注文字' })[0])
|
||
expect(screen.getAllByRole('button', { name: '显示标注点备注文字' })).toHaveLength(2)
|
||
commentLabels.forEach((label) => expect(label).toHaveClass('hidden'))
|
||
await user.click(screen.getAllByRole('button', { name: '显示标注点备注文字' })[0])
|
||
expect(screen.getAllByRole('button', { name: '隐藏标注点备注文字' })).toHaveLength(2)
|
||
commentLabels.forEach((label) => expect(label).not.toHaveClass('hidden'))
|
||
|
||
await user.click(screen.getByRole('button', { name: '提交给修改人' }))
|
||
|
||
await waitFor(() => expect(onSubmitReview).toHaveBeenCalledTimes(1))
|
||
expect(onSubmitReview.mock.calls[0][1]).toMatchObject({
|
||
reviewerRole: 'manager',
|
||
imageComments: [{
|
||
imageIndex: 0,
|
||
decision: 'revision_required',
|
||
annotations: [{
|
||
x: 0.75,
|
||
y: 0.6,
|
||
x2: 0.85,
|
||
y2: 0.7,
|
||
shape: 'rectangle',
|
||
color: '#d92d20',
|
||
comment: '人物位置向左移动',
|
||
}],
|
||
}],
|
||
})
|
||
})
|
||
|
||
test('requires the reviewer to complete the current annotation before submitting', async () => {
|
||
window.PointerEvent = MouseEvent as typeof PointerEvent
|
||
const user = userEvent.setup()
|
||
const onSubmitReview = jest.fn().mockResolvedValue(undefined)
|
||
render(<WorkReviewReviewerWorkspace project={project} onSubmitReview={onSubmitReview} />)
|
||
|
||
await user.click(screen.getAllByRole('button', { name: '+ 添加标注' })[0])
|
||
const canvas = screen.getAllByRole('group', { name: /作品长图,可添加位置批注/ })[0]
|
||
jest.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||
x: 0,
|
||
y: 0,
|
||
top: 0,
|
||
left: 0,
|
||
right: 1000,
|
||
bottom: 2000,
|
||
width: 1000,
|
||
height: 2000,
|
||
toJSON: () => ({}),
|
||
})
|
||
fireEvent.pointerDown(canvas, { button: 0, clientX: 250, clientY: 500 })
|
||
fireEvent.pointerMove(canvas, { button: 0, clientX: 500, clientY: 900 })
|
||
fireEvent.pointerUp(canvas, { button: 0, clientX: 500, clientY: 900 })
|
||
await user.type(screen.getByRole('textbox', { name: /图 1 · 标注 1 修改意见/ }), '需要调整')
|
||
|
||
await user.click(screen.getByRole('button', { name: '提交给修改人' }))
|
||
|
||
expect(onSubmitReview).not.toHaveBeenCalled()
|
||
expect(screen.getByRole('alert')).toHaveTextContent('请先点击“完成”锁定当前标注位置')
|
||
})
|
||
|
||
test('approves directly without requiring a modification annotation', async () => {
|
||
const user = userEvent.setup()
|
||
const onSubmitReview = jest.fn().mockResolvedValue(undefined)
|
||
render(<WorkReviewReviewerWorkspace project={project} onSubmitReview={onSubmitReview} />)
|
||
|
||
await user.click(screen.getByRole('button', { name: '通过' }))
|
||
|
||
await waitFor(() => expect(onSubmitReview).toHaveBeenCalledTimes(1))
|
||
expect(onSubmitReview.mock.calls[0][1]).toMatchObject({
|
||
reviewerRole: 'manager',
|
||
imageComments: [{
|
||
imageIndex: 0,
|
||
decision: 'approved',
|
||
comment: '通过',
|
||
annotations: [],
|
||
}],
|
||
})
|
||
})
|
||
|
||
test('keeps mobile work and reference controls synchronized with the comparison scroller', async () => {
|
||
const user = userEvent.setup()
|
||
render(<WorkReviewReviewerWorkspace project={projectWithReference} onSubmitReview={jest.fn()} />)
|
||
|
||
const scroller = screen.getByLabelText('作品与参考图片对照区,左右滑动切换')
|
||
const scrollTo = jest.fn()
|
||
Object.defineProperty(scroller, 'clientWidth', { configurable: true, value: 360 })
|
||
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo })
|
||
|
||
const workButton = screen.getByRole('button', { name: '作品图' })
|
||
const referenceButton = screen.getByRole('button', { name: '参考图' })
|
||
expect(workButton).toHaveAttribute('aria-pressed', 'true')
|
||
expect(referenceButton).toHaveAttribute('aria-pressed', 'false')
|
||
|
||
await user.click(referenceButton)
|
||
expect(scrollTo).toHaveBeenCalledWith({ left: 360, behavior: 'smooth' })
|
||
expect(referenceButton).toHaveAttribute('aria-pressed', 'true')
|
||
|
||
Object.defineProperty(scroller, 'scrollLeft', { configurable: true, value: 0 })
|
||
fireEvent.scroll(scroller)
|
||
expect(workButton).toHaveAttribute('aria-pressed', 'true')
|
||
})
|
||
|
||
test('does not show comparison controls when the version has no reference image', () => {
|
||
render(<WorkReviewReviewerWorkspace project={project} onSubmitReview={jest.fn()} />)
|
||
expect(screen.queryByRole('button', { name: '作品图' })).not.toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: '参考图' })).not.toBeInTheDocument()
|
||
})
|
||
|
||
test('shows the shared detail workspace in read-only mode for a submitter awaiting review', () => {
|
||
render(
|
||
<WorkReviewReviewerWorkspace
|
||
project={{
|
||
...project,
|
||
permissions: { canResubmit: false, pendingReviewRoles: [] },
|
||
}}
|
||
onSubmitReview={jest.fn()}
|
||
/>,
|
||
)
|
||
|
||
expect(screen.getByRole('tab', { name: 'V1' })).toBeInTheDocument()
|
||
expect(screen.getByText('等待评审人完成本轮评审;完成后系统会通知发起人')).toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: '通过' })).not.toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: '提交给修改人' })).not.toBeInTheDocument()
|
||
})
|
||
|
||
test('guides the submitter to upload a revision only when changes are required', () => {
|
||
render(
|
||
<WorkReviewReviewerWorkspace
|
||
project={{
|
||
...project,
|
||
status: 'revision_required',
|
||
versions: [{ ...project.versions[0], status: 'revision_required' }],
|
||
permissions: { canResubmit: true, pendingReviewRoles: [] },
|
||
}}
|
||
onSubmitReview={jest.fn()}
|
||
/>,
|
||
)
|
||
|
||
expect(screen.getByText('当前版本需要修改,请通过顶部“提交修改”上传新版本')).toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: '通过' })).not.toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: '提交给修改人' })).not.toBeInTheDocument()
|
||
})
|
||
|
||
test('treats an approved result as the completed end state', () => {
|
||
render(
|
||
<WorkReviewReviewerWorkspace
|
||
project={{
|
||
...project,
|
||
status: 'approved',
|
||
versions: [{ ...project.versions[0], status: 'approved', completedAt: '2026-08-01T08:00:00.000Z' }],
|
||
permissions: { canResubmit: true, pendingReviewRoles: [] },
|
||
}}
|
||
onSubmitReview={jest.fn()}
|
||
/>,
|
||
)
|
||
|
||
expect(screen.getByText('作品评审已通过,本作品已完结')).toBeInTheDocument()
|
||
expect(screen.getByRole('tab', { name: 'V1已过审' })).toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: '提交给修改人' })).not.toBeInTheDocument()
|
||
})
|
||
|
||
test('shows the single-review test mode and hides skipped final-reviewer annotations', () => {
|
||
render(
|
||
<WorkReviewReviewerWorkspace
|
||
project={{
|
||
...project,
|
||
reviewMode: 'single_reviewer_test',
|
||
requiredReviewRoles: ['manager'],
|
||
versions: [{
|
||
...project.versions[0],
|
||
reviews: [{
|
||
id: 'skipped-review',
|
||
reviewerRole: 'final_reviewer',
|
||
reviewerName: '元泽',
|
||
decision: 'revision_required',
|
||
imageComments: [{
|
||
imageIndex: 0,
|
||
decision: 'revision_required',
|
||
comment: '不应显示的历史意见',
|
||
annotations: [{ id: 'skipped-point', x: 0.5, y: 0.5, comment: '跳过此标注' }],
|
||
}],
|
||
generalComment: null,
|
||
reviewedAt: '2026-08-01T08:00:00.000Z',
|
||
}],
|
||
}],
|
||
}}
|
||
onSubmitReview={jest.fn()}
|
||
/>,
|
||
)
|
||
|
||
expect(screen.getByRole('status')).toHaveTextContent('已跳过元泽')
|
||
expect(screen.queryByText('跳过此标注')).not.toBeInTheDocument()
|
||
expect(screen.queryByText('不应显示的历史意见')).not.toBeInTheDocument()
|
||
})
|
||
})
|