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() { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void const promise = new Promise((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, values: Record) { 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() 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() const second = deferred() 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('') 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"') }) })