Files
admin-web/src/modules/appointment-calendar/WorkCommandForm.test.ts
T
2026-10-20 23:34:11 +08:00

112 lines
9.3 KiB
TypeScript

import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkCommandForm from './WorkCommandForm.vue'
import { submitWorkCommand, type WorkAction } from './workApi'
import { ApiResponseError } from '@/shared/api/http'
import { followAction, workDetail, workItem } from './work.test-support'
import type { LeaveGuard } from './useWorkDraft'
const session = vi.hoisted(() => ({ canWrite: true }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'user-1', name: '测试调度' } }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => session.canWrite }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), submitWorkCommand: vi.fn() }))
let guard: LeaveGuard
function render(action: WorkAction = followAction, detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) })) {
return mount(WorkCommandForm, { props: { detail, action, refreshing: false }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true,
} } })
}
beforeEach(() => { vi.clearAllMocks(); session.canWrite = true; vi.mocked(submitWorkCommand).mockResolvedValue({ item: workItem(), affectedWorkItemKeys: [] }) })
it('sends only follow-up ownership and feedback, retaining the undated work and the exact retry command', async () => {
vi.mocked(submitWorkCommand).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = render()
await wrapper.get('textarea').setValue('已联系,等待回复'); await wrapper.get('form').trigger('submit'); await flushPromises()
const first = vi.mocked(submitWorkCommand).mock.calls[0]![1]
expect(first).toMatchObject({ command: 'SAVE_FOLLOWUP', expectedVersion: 3, payload: { ownerId: 'user-1', note: '已联系,等待回复', followupDate: '2026-10-15' } })
expect(first.payload).not.toHaveProperty('target'); expect(await guard()).toBe(false)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenNthCalledWith(2, 'work-delivery-1', first)
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('preserves entered feedback after a conflict and uses the newly read version on an explicit resubmission', async () => {
vi.mocked(submitWorkCommand).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
const wrapper = render(); await wrapper.get('textarea').setValue('保留此反馈')
await wrapper.get('form').trigger('submit'); await flushPromises()
const first = vi.mocked(submitWorkCommand).mock.calls[0]![1]
expect(wrapper.emitted('refresh')).toHaveLength(1)
await wrapper.setProps({ detail: workDetail({ item: workItem({ version: 4 }) }) })
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('保留此反馈')
await wrapper.get('form').trigger('submit'); await flushPromises()
const second = vi.mocked(submitWorkCommand).mock.calls[1]![1]
expect(second.expectedVersion).toBe(4); expect(second.idempotencyKey).not.toBe(first.idempotencyKey)
wrapper.unmount()
})
it('uses the distinct review version and never substitutes the item version or marks work complete', async () => {
const action: WorkAction = { code: 'RESOLVE_REVIEW', label: '复核安排', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'RESOLVE_REVIEW' } }
const wrapper = render(action, workDetail({ item: workItem({ allowedActions: [action], reviewRequired: true }), review: { version: 12, reasons: ['到港日期调整'] } }))
await wrapper.get('textarea').setValue('已核对原安排'); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ expectedVersion: 3, command: 'RESOLVE_REVIEW', payload: { reviewVersion: 12, note: '已核对原安排' } }))
wrapper.unmount()
})
it('requires receiving instructions for a customer warehouse that needs no appointment and does not invent a time', async () => {
const action: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记收货要求', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'SAVE_REQUIREMENT' } }
const wrapper = render(action)
await wrapper.get('input[type=checkbox]').setValue(false)
await wrapper.findAll('textarea')[1]!.setValue('客户仓确认无需预约')
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
await wrapper.findAll('textarea')[0]!.setValue('工作日9至16点到仓联系收货人')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ command: 'SAVE_REQUIREMENT', payload: { target: null, bookingRequired: false, receivingRequirements: '工作日9至16点到仓联系收货人', reason: '客户仓确认无需预约' } }))
wrapper.unmount()
})
it('keeps a return handoff separate from original carrier plans and records external acceptance evidence', async () => {
const action: WorkAction = { code: 'ACCEPT_RETURN', label: '记录接手反馈', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'ACCEPT_RETURN' } }
const wrapper = render(action, workDetail({ item: workItem({ type: 'EMPTY_RETURN', allowedActions: [action] }), returnHandoff: { version: 7, from: { kind: 'USER', id: 'user-1', name: '测试调度' }, to: { kind: 'EXTERNAL', id: null, name: '测试还空车队' }, status: 'PENDING_ACCEPTANCE', reason: '原车次调整' } }))
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
await wrapper.get('textarea').setValue('接手车队已回复确认'); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ command: 'ACCEPT_RETURN', payload: { handoffVersion: 7, note: '接手车队已回复确认' } }))
wrapper.unmount()
})
it('denies command submission without write permission even when a submit event is forced', async () => {
session.canWrite = false; const wrapper = render()
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('当前仅可查看'); wrapper.unmount()
})
const requirementAction: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记收货要求', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'SAVE_REQUIREMENT' } }
it('preserves an explicit cross-day local interval in the requirement request', async () => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('跨日收货窗口')
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue('23:00')
await dates[1]!.setValue('2026-10-16'); await times[1]!.setValue('01:00')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: '23:00', endDate: '2026-10-16', endTime: '01:00', timezone: 'America/Los_Angeles' } }) }))
wrapper.unmount()
})
it.each([
['2026-10-16', '', '23:00'], ['', '01:00', '23:00'],
['2026-10-15', '22:00', '23:00'], ['2026-10-15', '23:00', '23:00'],
['2026-10-16', '01:00', ''],
])('rejects incomplete or non-increasing interval %s %s %s without inferring a next day', async (endDate, endTime, startTime) => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('核对时间')
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue(startTime)
await dates[1]!.setValue(endDate); await times[1]!.setValue(endTime)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).not.toHaveBeenCalled(); wrapper.unmount()
})
it('saves a date-only requirement with all time fields null', async () => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('具体时段待回复')
await wrapper.findAll('input[data-picker-kind=date]')[0]!.setValue('2026-10-15')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: null, endDate: null, endTime: null, timezone: 'America/Los_Angeles' } }) }))
wrapper.unmount()
})