Files
admin-web/src/modules/outbound-dispatch/pages/OutboundDispatchesView.test.ts
T

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"')
})
})