183 lines
10 KiB
TypeScript
183 lines
10 KiB
TypeScript
import ElementPlus from 'element-plus'
|
|
import { readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
|
import DataTable from '@/shared/components/DataTable.vue'
|
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
|
import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/unified-orders'
|
|
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
|
|
import { getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
|
|
import type { OutboundDispatchResource } from '../types'
|
|
import OutboundDispatchesView from './OutboundDispatchesView.vue'
|
|
|
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
|
vi.mock('@/modules/unified-orders', () => ({ listPlatformUnifiedOrders: vi.fn() }))
|
|
vi.mock('@/modules/container-loading', () => ({ listContainers: vi.fn() }))
|
|
vi.mock('../api/platformOutboundDispatch', () => ({ listOutboundDispatches: vi.fn(), getOutboundDispatchDetail: vi.fn(), changeOutboundDispatch: vi.fn() }))
|
|
|
|
const customer = { id: '10001', tenantCode: 'CUS-1', tenantName: '示例客户', legalName: '示例客户有限公司', status: 'ACTIVE' } as PlatformTenantResource
|
|
const order = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户' } as UnifiedOrderResource
|
|
const secondOrder = { ...order, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' } as UnifiedOrderResource
|
|
const container = { id: '40001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
|
|
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001', oceanBookingStatus: 'RELEASED', truckAppointmentStatus: 'COMPLETED', pickTaskStatus: 'AWAITING_REVIEW', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason?: unknown) => void
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
function mountPage() {
|
|
return mount(OutboundDispatchesView, { attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
|
|
}
|
|
|
|
async function setFilters(wrapper: ReturnType<typeof mountPage>, values: Record<string, string>) {
|
|
const toolbar = wrapper.findComponent(QueryToolbar)
|
|
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values })
|
|
await flushPromises()
|
|
return toolbar
|
|
}
|
|
|
|
describe('OutboundDispatchesView', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
setActivePinia(createPinia())
|
|
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
|
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [order, secondOrder], page: 1, size: 200, total: 2, pages: 1 })
|
|
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 200, total: 1, pages: 1 })
|
|
vi.mocked(listOutboundDispatches).mockResolvedValue([row])
|
|
})
|
|
|
|
it('starts idle and queries real ids selected through business labels', async () => {
|
|
const wrapper = mountPage()
|
|
await flushPromises()
|
|
expect(listOutboundDispatches).not.toHaveBeenCalled()
|
|
expect(wrapper.text()).toContain('请选择入仓码或柜次后查询')
|
|
const toolbar = await setFilters(wrapper, { orderId: '20001' })
|
|
toolbar.vm.$emit('search')
|
|
await flushPromises()
|
|
expect(listOutboundDispatches).toHaveBeenCalledOnce()
|
|
expect(listOutboundDispatches).toHaveBeenCalledWith({ orderId: '20001', inboundId: '30001', containerBookingId: undefined })
|
|
expect(wrapper.text()).toContain('IN-001')
|
|
expect(wrapper.text()).not.toContain('ORD-001')
|
|
expect(wrapper.text()).toContain('已放舱')
|
|
expect(wrapper.text()).toContain('已完成')
|
|
expect(wrapper.text()).toContain('待复核')
|
|
expect(wrapper.text()).toContain('待确认出仓')
|
|
expect(wrapper.text()).not.toContain('READY_FOR_DISPATCH')
|
|
})
|
|
|
|
it('loads only active customer names for the filter', () => {
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
|
|
expect(source).toContain("listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })")
|
|
expect(source).toContain("result.records.filter((customer) => customer.status === 'ACTIVE')")
|
|
expect(source).toContain('while (currentPage <= pages)')
|
|
expect(source).toContain("item.tenantName")
|
|
expect(source).not.toContain('item.tenantCode')
|
|
})
|
|
|
|
it('does not issue a search when reset returns to the initial state', async () => {
|
|
const wrapper = mountPage()
|
|
await flushPromises()
|
|
const toolbar = await setFilters(wrapper, { containerBookingId: '40001' })
|
|
toolbar.vm.$emit('search')
|
|
await flushPromises()
|
|
expect(listOutboundDispatches).toHaveBeenCalledOnce()
|
|
const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置'))
|
|
await resetButton?.trigger('click')
|
|
await flushPromises()
|
|
expect(listOutboundDispatches).toHaveBeenCalledOnce()
|
|
expect(wrapper.text()).toContain('请选择入仓码或柜次后查询')
|
|
})
|
|
|
|
it('shows loading, empty and recoverable error states', async () => {
|
|
const pending = deferred<OutboundDispatchResource[]>()
|
|
vi.mocked(listOutboundDispatches).mockReturnValueOnce(pending.promise)
|
|
const wrapper = mountPage()
|
|
await flushPromises()
|
|
const toolbar = await setFilters(wrapper, { containerBookingId: '40001' })
|
|
toolbar.vm.$emit('search')
|
|
await flushPromises()
|
|
expect(wrapper.findComponent(DataTable).props('loading')).toBe(true)
|
|
pending.resolve([])
|
|
await flushPromises()
|
|
expect(wrapper.text()).toContain('暂无符合条件的出仓记录')
|
|
|
|
vi.mocked(listOutboundDispatches).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce([row])
|
|
toolbar.vm.$emit('search')
|
|
await flushPromises()
|
|
expect(wrapper.text()).toContain('国内出仓加载失败')
|
|
const retry = wrapper.findAll('button').find((button) => button.text().includes('重新加载'))
|
|
await retry?.trigger('click')
|
|
await flushPromises()
|
|
expect(listOutboundDispatches).toHaveBeenCalledTimes(3)
|
|
expect(wrapper.text()).toContain('IN-001')
|
|
expect(wrapper.text()).not.toContain('ORD-001')
|
|
})
|
|
|
|
it('ignores stale list responses when a newer query wins', async () => {
|
|
const first = deferred<OutboundDispatchResource[]>()
|
|
const second = deferred<OutboundDispatchResource[]>()
|
|
vi.mocked(listOutboundDispatches).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
|
const wrapper = mountPage()
|
|
await flushPromises()
|
|
let toolbar = await setFilters(wrapper, { orderId: '20001' })
|
|
toolbar.vm.$emit('search')
|
|
toolbar = await setFilters(wrapper, { orderId: '20002' })
|
|
toolbar.vm.$emit('search')
|
|
second.resolve([{ ...row, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' }])
|
|
await flushPromises()
|
|
first.resolve([row])
|
|
await flushPromises()
|
|
expect(wrapper.text()).toContain('IN-002')
|
|
expect(wrapper.text()).not.toContain('IN-001')
|
|
expect(wrapper.text()).not.toContain('ORD-002')
|
|
})
|
|
|
|
it('keeps the operation column first and guarded workflow actions', () => {
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
|
|
const columnSource = source.slice(source.indexOf('const columns'))
|
|
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("prop: 'inboundCode'"))
|
|
expect(columnSource).not.toContain("prop: 'orderNo'")
|
|
expect(columnSource.indexOf("prop: 'cabinetNo', label: '柜次号'")).toBeLessThan(columnSource.indexOf("prop: 'containerNo', label: '集装箱号'"))
|
|
expect(columnSource).toContain("fixed: 'left'")
|
|
expect(source).toContain('aggregate.gate?.items')
|
|
expect(source).toContain('detail.timeline')
|
|
expect(source).toContain("'confirm-dispatch'")
|
|
expect(source).toContain("'reverse-dispatch'")
|
|
expect(source).toContain("row.dispatchStatus === 'READY_FOR_DISPATCH'")
|
|
expect(source).toContain("row.dispatchStatus === 'WAITING_FOR_LOADING'")
|
|
expect(source).toContain('timelineModuleLabel(entry.module)')
|
|
expect(source).toContain('timelineActionLabel(entry.action)')
|
|
expect(source).toContain('timelineStatusLabel(entry.module, entry.fromStatus)')
|
|
expect(source).toContain("error.value?.code === 'A0409'")
|
|
expect(getOutboundDispatchDetail).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the gate detail dialog responsive with internal scrolling', () => {
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
|
|
expect(source).toContain('width="min(900px, calc(100vw - 28px))"')
|
|
expect(source).toContain('max-height: 90vh')
|
|
expect(source).toContain('overflow-y: auto')
|
|
})
|
|
it('styles the native gate table with prototype borders, stripes and hover', () => {
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
|
|
expect(source).toContain('<el-table class="gate-table" :data="detail.aggregate.gate?.items || []" border stripe>')
|
|
expect(source).toContain('.gate-table { --el-table-row-hover-bg-color: #f1f7ff; }')
|
|
})
|
|
it('uses semantic bordered and filled business buttons instead of text links', () => {
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
|
|
expect(source).toContain('size="small" type="primary" plain')
|
|
expect(source).toContain('size="small" type="primary" @click="action')
|
|
expect(source).toContain('size="small" type="danger" plain')
|
|
expect(source).not.toContain("outbound-dispatch:view')\" link")
|
|
expect(source).toContain(':loading="loading" stripe row-key="inboundId"')
|
|
})
|
|
})
|