194 lines
15 KiB
TypeScript
194 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { createRequire } from 'node:module'
|
|
import DomesticContainersView from './DomesticContainersView.vue'
|
|
import DataTable from '@/shared/components/DataTable.vue'
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
|
import * as api from '../api/domesticContainers'
|
|
import type { InventoryPage, InventoryRecord } from '../types'
|
|
import { readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
// Use Node's supported require export so the validator retains its constructor interop.
|
|
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/DomesticContainersView.vue'), 'utf8')
|
|
const summaryStyles = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/domesticContainersSummary.css'), 'utf8')
|
|
describe('DomesticContainersView', () => {
|
|
it('uses formal shared list and centered dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'"); expect(source).toContain('width="min(720px, calc(100vw - 28px))"') })
|
|
it('gates create actions with backend permissions', () => { expect(source).toContain("access.can('platform:domestic-container:inbound:create')"); expect(source).toContain("access.can('platform:domestic-container:outbound:create')") })
|
|
it('loads only active domestic warehouse candidates and surfaces errors', () => { expect(source).toContain("listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })"); expect(source).toContain('国内仓候选加载失败') })
|
|
it('keeps idempotency key stable through retry and handles conflicts by refreshing inventory', () => { expect(source).toContain('idempotencyKey.value'); expect(source).toContain("submitError.value.code === 'A0409'"); expect(source).toContain('await loadCandidates()'); expect(source).not.toContain('localStorage') })
|
|
it('keeps business tabs in the list toolbar with keyboard interaction and separate filters', () => {
|
|
const toolbar = source.match(/<template #toolbar-start>([\s\S]*?)<\/template>/)?.[1]
|
|
const filters = source.match(/<template #filters>([\s\S]*?)<\/template>/)?.[1]
|
|
expect(toolbar).toContain('class="status-tabs density-status-tabs workbench-list-tabs"')
|
|
expect(toolbar).toContain('role="tablist"')
|
|
expect(toolbar).toContain('role="tab"')
|
|
expect(toolbar).toContain('@click="switchTab(tab.value)"')
|
|
expect(toolbar).toContain('@keydown="handleTabKeydown($event, index)"')
|
|
expect(toolbar).not.toContain('<h1>')
|
|
expect(filters).toContain('class="business-query-panel integrated-query-group"')
|
|
expect(filters).toContain('<QueryToolbar')
|
|
expect(filters).not.toContain('role="tablist"')
|
|
expect(source).not.toContain('<el-tabs')
|
|
})
|
|
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
|
|
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
|
|
it('keeps all four inventory facts and units in shared compact metric cards', () => {
|
|
for (const fact of ['summary?.inboundBoxes', 'summary?.outboundBoxes', 'summary?.availableBoxes', 'summary?.inboundVolumeCbm']) expect(source).toContain(fact)
|
|
expect(source).toContain("import './domesticContainersSummary.css'")
|
|
const summary = source.match(/<section\b[^>]*class="inventory-summary"[\s\S]*?<\/section>/)?.[0] || ''
|
|
expect(summary).not.toContain('v-if=')
|
|
expect(summary.match(/class="workbench-metric workbench-tone is-compact"/g)).toHaveLength(4)
|
|
expect(summary.match(/<span>箱<\/span>/g)).toHaveLength(3)
|
|
expect(summary).toContain("summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span>")
|
|
expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
|
|
expect(summaryStyles).toContain('gap: var(--workbench-grid-gap)')
|
|
expect(summaryStyles).not.toContain('position: absolute')
|
|
})
|
|
it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") })
|
|
it('matches backend numeric and container-number input boundaries before submit', () => { expect(source).toContain("pattern: /^[A-Za-z0-9-]{4,32}$/"); expect(source).toContain(':max="999999999"'); expect(source).toContain(':max="999999999999.9999"') })
|
|
it('requires a refreshed inventory version for completed outbound submission', () => { expect(source).toContain("outboundForm.inventoryVersion == null"); expect(source).toContain('库存版本未就绪,请重新选择集装箱') })
|
|
})
|
|
|
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
|
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([{ id: 'warehouse-1', warehouseCode: 'WH-01', warehouseName: '测试仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' }]) }))
|
|
vi.mock('../api/domesticContainers', () => ({ createDomesticContainerInbound: vi.fn(), createDomesticContainerOutbound: vi.fn(), getDomesticContainerInventory: vi.fn(), listDomesticContainerInbounds: vi.fn(), listDomesticContainerInventories: vi.fn(), listDomesticContainerOutbounds: vi.fn() }))
|
|
const inventory: InventoryRecord = { id: 'inventory-1', warehouseId: 'warehouse-1', warehouseName: '测试仓', containerNo: 'MSCU1234567', cargoName: '测试货物', availableBoxes: 8, inboundBoxes: 10, outboundBoxes: 2, inboundVolumeCbm: 2.5, inventoryStatus: 'PARTIALLY_OUTBOUND', version: 3, updatedAt: '2026-09-30T10:00:00' }
|
|
let mounted: VueWrapper | undefined
|
|
async function mountView() {
|
|
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
|
mounted = view
|
|
await flushPromises()
|
|
return view
|
|
}
|
|
async function openCreate(label: string) {
|
|
const view = await mountView()
|
|
if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
|
|
await flushPromises()
|
|
await view.findAll('button').find(button => button.text() === label)!.trigger('click')
|
|
await flushPromises()
|
|
const form = view.findComponent({ name: 'ElForm' })
|
|
return { view, form }
|
|
}
|
|
async function submitCreate() {
|
|
document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
|
|
await flushPromises()
|
|
}
|
|
describe('DomesticContainersView workflows', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
|
vi.mocked(api.listDomesticContainerInbounds).mockResolvedValue(empty)
|
|
vi.mocked(api.listDomesticContainerOutbounds).mockResolvedValue(empty)
|
|
vi.mocked(api.listDomesticContainerInventories).mockResolvedValue({ ...empty, records: [inventory], summary: { inboundBoxes: 10, outboundBoxes: 2, availableBoxes: 8, inboundVolumeCbm: 2.5 } })
|
|
vi.mocked(api.getDomesticContainerInventory).mockResolvedValue({ inventory: { ...inventory, availableBoxes: 6, version: 9 }, inboundOrderCount: 1, outboundOrderCount: 1, transactions: [] })
|
|
})
|
|
afterEach(() => { mounted?.unmount(); mounted = undefined; document.body.innerHTML = '' })
|
|
|
|
it('loads the overview on entry and keeps it above the list across all three tabs', async () => {
|
|
const view = await mountView()
|
|
const overview = view.get('.inventory-summary')
|
|
expect(view.element.firstElementChild).toBe(overview.element)
|
|
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
|
|
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(1)
|
|
for (const label of ['整柜出库单', '整柜库存', '整柜入库单']) {
|
|
const requests = vi.mocked(api.listDomesticContainerInventories).mock.calls.length
|
|
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
|
|
await flushPromises()
|
|
expect(view.element.firstElementChild).toBe(overview.element)
|
|
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
|
|
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(requests + 1)
|
|
}
|
|
})
|
|
|
|
it.each(['整柜入库单', '整柜出库单'])('uses common filters for the overview on %s without passing order-only filters', async label => {
|
|
const view = await mountView()
|
|
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
|
|
await flushPromises()
|
|
const filters = { warehouseId: 'warehouse-1', containerNo: 'MSCU1234567', cargoName: '测试货物', orderNo: 'ORDER-01', status: 'PENDING' }
|
|
view.findComponent(QueryToolbar).vm.$emit('search', filters)
|
|
await flushPromises()
|
|
expect(api.listDomesticContainerInventories).toHaveBeenLastCalledWith({ page: 1, size: 1, warehouseId: filters.warehouseId, containerNo: filters.containerNo, cargoName: filters.cargoName })
|
|
const list = label === '整柜入库单' ? api.listDomesticContainerInbounds : api.listDomesticContainerOutbounds
|
|
expect(list).toHaveBeenLastCalledWith({ ...filters, page: 1, size: 20 })
|
|
})
|
|
|
|
it('shows unknown summary values on failure while keeping the order list available and allowing retry', async () => {
|
|
vi.mocked(api.listDomesticContainerInventories).mockRejectedValueOnce(new Error('统计失败'))
|
|
const view = await mountView()
|
|
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['—', '—', '—', '—'])
|
|
expect(view.findComponent(DataTable).props('resultsVisible')).toBe(true)
|
|
const error = view.findComponent(LoadErrorState)
|
|
expect(error.props('title')).toBe('整柜库存统计加载失败')
|
|
error.vm.$emit('retry')
|
|
await flushPromises()
|
|
expect(view.findComponent(LoadErrorState).exists()).toBe(false)
|
|
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
|
|
})
|
|
|
|
it('ignores a stale overview response after switching tabs', async () => {
|
|
let resolveFirst!: (value: InventoryPage) => void
|
|
vi.mocked(api.listDomesticContainerInventories).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
|
|
const view = await mountView()
|
|
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('true')
|
|
await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
|
|
await flushPromises()
|
|
resolveFirst({ records: [], page: 1, size: 1, total: 0, pages: 0, summary: { inboundBoxes: 99, outboundBoxes: 0, availableBoxes: 99, inboundVolumeCbm: 99 } })
|
|
await flushPromises()
|
|
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
|
|
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('false')
|
|
})
|
|
|
|
it('keeps validation, normalized input and the idempotency key when retrying an inbound', async () => {
|
|
vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
|
|
const { form } = await openCreate('新建入库单')
|
|
await submitCreate()
|
|
expect(api.createDomesticContainerInbound).not.toHaveBeenCalled()
|
|
const fields = form.findAllComponents({ name: 'ElFormItem' })
|
|
const warehouse = fields.find(item => item.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' })
|
|
warehouse.vm.$emit('update:modelValue', 'warehouse-1')
|
|
await fields.find(item => item.props('prop') === 'cargoName')!.find('input').setValue(' 测试货物 ')
|
|
await fields.find(item => item.props('prop') === 'containerNo')!.find('input').setValue('mscu1234567')
|
|
fields.find(item => item.props('prop') === 'boxCount')!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 4)
|
|
fields.find(item => item.props('prop') === 'volumeCbm')!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1.2345)
|
|
await flushPromises()
|
|
await submitCreate()
|
|
expect(document.body.textContent).toContain('整柜单据创建失败')
|
|
await submitCreate()
|
|
const calls = vi.mocked(api.createDomesticContainerInbound).mock.calls
|
|
expect(calls).toHaveLength(2)
|
|
expect(calls[0][0]).toEqual(calls[1][0])
|
|
expect(calls[1][0]).toMatchObject({ warehouseId: 'warehouse-1', cargoName: '测试货物', containerNo: 'MSCU1234567', boxCount: 4, volumeCbm: 1.2345, status: 'COMPLETED', idempotencyKey: expect.any(String) })
|
|
})
|
|
|
|
it('shows inventory facts as text and submits the refreshed version only within available stock', async () => {
|
|
vi.mocked(api.createDomesticContainerOutbound).mockResolvedValue({ id: 'order-internal', orderNo: 'ZGCK-01', replayed: false })
|
|
const { form } = await openCreate('新建出库单')
|
|
const fields = form.findAllComponents({ name: 'ElFormItem' })
|
|
const warehouse = fields.find(item => item.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' })
|
|
warehouse.vm.$emit('update:modelValue', 'warehouse-1')
|
|
warehouse.vm.$emit('change', 'warehouse-1')
|
|
await flushPromises()
|
|
const selection = fields.find(item => item.props('prop') === 'containerNo')!.findComponent({ name: 'ElSelect' })
|
|
selection.vm.$emit('update:modelValue', 'inventory-1')
|
|
selection.vm.$emit('change', 'inventory-1')
|
|
await flushPromises()
|
|
const cargo = fields.find(item => item.props('label') === '货物名称')!
|
|
expect(cargo.text()).toContain('测试货物')
|
|
expect(cargo.find('input').exists()).toBe(false)
|
|
expect(fields.find(item => item.props('label') === '可用库存')!.text()).toContain('6 箱')
|
|
const quantity = fields.find(item => item.props('prop') === 'boxCount')!.findComponent({ name: 'ElInputNumber' })
|
|
quantity.vm.$emit('update:modelValue', 7)
|
|
await flushPromises()
|
|
await submitCreate()
|
|
expect(api.createDomesticContainerOutbound).not.toHaveBeenCalled()
|
|
expect(document.body.textContent).toContain('库存不足,当前可用 6 箱')
|
|
quantity.vm.$emit('update:modelValue', 5)
|
|
await flushPromises()
|
|
await submitCreate()
|
|
expect(api.createDomesticContainerOutbound).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ warehouseId: 'warehouse-1', containerNo: 'MSCU1234567', boxCount: 5, inventoryVersion: 9, idempotencyKey: expect.any(String) }))
|
|
})
|
|
})
|