2026-10-21 13:22:42 +08:00
|
|
|
import ElementPlus from 'element-plus'
|
|
|
|
|
import { defineComponent, h } from 'vue'
|
|
|
|
|
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
|
|
|
|
import { candidate, sharedPalletCandidates } from '../dispatchTestFixtures'
|
|
|
|
|
import type { Candidate, DispatchDraft } from '../dispatchContracts'
|
|
|
|
|
|
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
|
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
|
|
|
|
function draft(): DispatchDraft {
|
|
|
|
|
return { draftToken: 'server-token', recordVersion: 2, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', sources: [], containers: [
|
|
|
|
|
{ draftContainerId: 'car-1', sequenceNo: 1, capacity: 30, sourceKeys: [] },
|
|
|
|
|
{ draftContainerId: 'car-2', sequenceNo: 2, capacity: 15, sourceKeys: [] },
|
|
|
|
|
] }
|
|
|
|
|
}
|
|
|
|
|
function render(candidates: Candidate[] = [candidate()], extra: Record<string, unknown> = {}) {
|
|
|
|
|
return mount(SmartDispatchCargoBoard, { props: { candidates, draft: draft(), activeContainerId: 'car-1', capacity: 30, disabled: false, warehouseSelected: true, page: 1, size: 20, total: candidates.length, ...extra }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
}
|
|
|
|
|
async function select(wrapper: VueWrapper, index = 0) { await wrapper.get('[aria-label="待排订单"]').findAll('tbody tr')[index]!.get('button').trigger('click') }
|
2026-10-21 14:30:33 +08:00
|
|
|
function pallet(wrapper: VueWrapper) { return wrapper.get('[aria-label="托盘明细"] tr[draggable]') }
|
2026-10-21 13:22:42 +08:00
|
|
|
function transfer() { return { setData: vi.fn(), getData: vi.fn(() => '{"sourceKey":"forged","containerId":"car-2"}'), effectAllowed: '', dropEffect: '' } }
|
|
|
|
|
|
|
|
|
|
describe('smart dispatch three-column cargo board', () => {
|
|
|
|
|
it('shows only the chosen order pallets and a default empty car without emitting writes', async () => {
|
|
|
|
|
const first = candidate(), second = candidate({ sourceKey: 'source-2', orderNo: 'ORDER-2', palletNo: 'PALLET-2' })
|
|
|
|
|
const wrapper = render([first, second], { draft: null, activeContainerId: '' })
|
|
|
|
|
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
|
2026-10-21 14:30:33 +08:00
|
|
|
expect(wrapper.get('[aria-label="托盘明细"]').findAll('thead th').map(cell => cell.text())).toEqual(['托盘号', '箱数'])
|
2026-10-21 13:22:42 +08:00
|
|
|
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(1)
|
|
|
|
|
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('30 托位')
|
|
|
|
|
await select(wrapper, 1)
|
|
|
|
|
expect(pallet(wrapper).text()).toContain('PALLET-2')
|
|
|
|
|
expect(wrapper.get('[aria-label="托盘明细"]').text()).not.toContain('PALLET-1')
|
|
|
|
|
expect(wrapper.emitted('add')).toBeUndefined()
|
|
|
|
|
await pallet(wrapper).get('button').trigger('click')
|
|
|
|
|
expect(wrapper.emitted('add')).toEqual([[second, '']])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('drops a current pallet into a non-active car using the drop target identity', async () => {
|
|
|
|
|
const source = candidate(), wrapper = render([source]), dataTransfer = transfer()
|
|
|
|
|
await select(wrapper)
|
|
|
|
|
await pallet(wrapper).trigger('dragstart', { dataTransfer })
|
|
|
|
|
await wrapper.get('[aria-label="第 2 辆车"]').trigger('dragover', { dataTransfer })
|
|
|
|
|
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop', { dataTransfer })
|
|
|
|
|
expect(wrapper.emitted('add')).toEqual([[source, 'car-2']])
|
|
|
|
|
expect(dataTransfer.getData).not.toHaveBeenCalled()
|
|
|
|
|
await wrapper.get('[aria-label="第 1 辆车"]').trigger('drop', { dataTransfer })
|
|
|
|
|
expect(wrapper.emitted('add')).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects external or forged drag payloads that were never selected inside this component', async () => {
|
|
|
|
|
const wrapper = render(), dataTransfer = transfer()
|
|
|
|
|
await select(wrapper)
|
|
|
|
|
await wrapper.get('[aria-label="第 2 辆车"]').trigger('dragover', { dataTransfer })
|
|
|
|
|
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop', { dataTransfer })
|
|
|
|
|
expect(wrapper.emitted('add')).toBeUndefined()
|
|
|
|
|
expect(dataTransfer.getData).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each(['refresh', 'page', 'size', 'disabled', 'draft'] as const)('rejects a previously started drag after %s changes', async change => {
|
|
|
|
|
const source = candidate(), wrapper = render([source]), dataTransfer = transfer()
|
|
|
|
|
await select(wrapper); await pallet(wrapper).trigger('dragstart', { dataTransfer })
|
|
|
|
|
if (change === 'refresh') await wrapper.setProps({ candidates: [clone(source)] })
|
|
|
|
|
if (change === 'page') await wrapper.setProps({ page: 2 })
|
|
|
|
|
if (change === 'size') await wrapper.setProps({ size: 10 })
|
|
|
|
|
if (change === 'disabled') await wrapper.setProps({ disabled: true })
|
|
|
|
|
if (change === 'draft') await wrapper.setProps({ draft: { ...draft(), recordVersion: 3 } })
|
|
|
|
|
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop', { dataTransfer })
|
|
|
|
|
expect(wrapper.emitted('add')).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('freezes add, removal, selection and pagination while a command is pending', async () => {
|
|
|
|
|
const source = candidate(), state = draft()
|
|
|
|
|
state.sources = [source]; state.containers[0]!.sourceKeys = [source.sourceKey]
|
|
|
|
|
const wrapper = render([source], { draft: state, total: 21 })
|
|
|
|
|
await select(wrapper); await wrapper.setProps({ disabled: true })
|
|
|
|
|
expect(wrapper.findAll('button').filter(button => ['添加车辆', '移除货物', '删除车辆', '查看托盘'].includes(button.text())).every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
|
|
|
|
expect(wrapper.findAll('input[type="radio"]').every(input => input.attributes('disabled') !== undefined)).toBe(true)
|
2026-10-21 15:49:35 +08:00
|
|
|
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
2026-10-21 13:22:42 +08:00
|
|
|
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop')
|
|
|
|
|
await wrapper.get('[aria-label="候选货物分页"] button.btn-next').trigger('click')
|
|
|
|
|
expect(wrapper.emitted('add')).toBeUndefined()
|
|
|
|
|
expect(wrapper.emitted('changePage')).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps a shared pallet as one full source group even when only one member is on the current page', async () => {
|
|
|
|
|
const sources = sharedPalletCandidates(), wrapper = render([sources[0]!])
|
|
|
|
|
await select(wrapper)
|
2026-10-21 14:30:33 +08:00
|
|
|
expect(wrapper.findAll('[aria-label="托盘明细"] tr[draggable]')).toHaveLength(1)
|
2026-10-21 13:22:42 +08:00
|
|
|
const group = pallet(wrapper)
|
|
|
|
|
expect(group.text()).toContain('整托关联货物一起加入')
|
|
|
|
|
expect(group.findAll('details li')).toHaveLength(2)
|
|
|
|
|
expect(group.text()).toContain('客户A'); expect(group.text()).toContain('客户B')
|
|
|
|
|
expect(group.text()).toContain('8 箱')
|
|
|
|
|
await group.get('button').trigger('click')
|
|
|
|
|
expect(wrapper.emitted('add')).toEqual([[sources[0], 'car-1']])
|
|
|
|
|
expect((wrapper.emitted('add')![0]![0] as Candidate).selectionGroup.sources).toHaveLength(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows a shared loaded pallet only once and blocks another member from being added to another car', async () => {
|
|
|
|
|
const sources = sharedPalletCandidates(), state = draft()
|
|
|
|
|
state.sources = sources; state.containers[0]!.sourceKeys = sources.map(row => row.sourceKey)
|
|
|
|
|
const wrapper = render(sources, { draft: state, activeContainerId: 'car-2' })
|
|
|
|
|
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
|
|
|
|
|
expect(wrapper.get('[aria-label="第 1 辆车"]').findAll('.cargo-board-loaded-pallet')).toHaveLength(1)
|
|
|
|
|
for (const index of [0, 1]) {
|
|
|
|
|
await select(wrapper, index)
|
2026-10-21 15:49:35 +08:00
|
|
|
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
2026-10-21 13:22:42 +08:00
|
|
|
}
|
|
|
|
|
expect(wrapper.emitted('add')).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-21 15:49:35 +08:00
|
|
|
it('moves a confirmed pallet out of the middle list and restores it when removed; only empty cars can be deleted', async () => {
|
|
|
|
|
const source = candidate(), state = draft(), wrapper = render([source])
|
|
|
|
|
await select(wrapper)
|
|
|
|
|
expect(pallet(wrapper).findAll('td')).toHaveLength(2)
|
|
|
|
|
expect(pallet(wrapper).get('button').text()).toBe('PALLET-1')
|
|
|
|
|
expect(wrapper.text()).not.toContain('加入这辆车')
|
|
|
|
|
state.sources = [source]; state.containers[0]!.sourceKeys = [source.sourceKey]
|
|
|
|
|
await wrapper.setProps({ draft: clone(state) })
|
|
|
|
|
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
|
|
|
|
const loaded = wrapper.get('[aria-label="第 1 辆车"]')
|
|
|
|
|
expect(loaded.text()).not.toContain('删除车辆')
|
|
|
|
|
expect(wrapper.get('[aria-label="第 2 辆车"]').text()).toContain('删除车辆')
|
|
|
|
|
await loaded.get('[aria-label="移回托盘 PALLET-1"]').trigger('click')
|
|
|
|
|
expect(wrapper.emitted('removeCargo')).toEqual([[source, 'car-1']])
|
|
|
|
|
await wrapper.setProps({ draft: draft() })
|
|
|
|
|
expect(pallet(wrapper).text()).toContain('PALLET-1')
|
|
|
|
|
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('删除车辆')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-21 13:22:42 +08:00
|
|
|
it('blocks incomplete server-owned groups and does not convert missing measurements to zero', async () => {
|
|
|
|
|
const source = candidate({ suggestedWeightKg: null, suggestedVolumeCbm: null })
|
|
|
|
|
source.selectionGroup.complete = false
|
|
|
|
|
source.selectionGroup.blockReasons = ['同托货物未全部返回']
|
|
|
|
|
const wrapper = render([source])
|
|
|
|
|
await select(wrapper)
|
|
|
|
|
expect(pallet(wrapper).attributes('draggable')).toBe('false')
|
|
|
|
|
expect(pallet(wrapper).get('button').attributes('disabled')).toBeDefined()
|
|
|
|
|
expect(pallet(wrapper).text()).toContain('同托货物未全部返回')
|
|
|
|
|
expect(wrapper.text()).not.toMatch(/0(?:\.0)?\s*(?:kg|m³)/)
|
|
|
|
|
expect(wrapper.emitted('add')).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps allocated cars visible across candidate pages and reports page events without pretending to have full orders', async () => {
|
|
|
|
|
const source = candidate(), state = draft()
|
|
|
|
|
state.sources = [source]; state.containers[0]!.sourceKeys = [source.sourceKey]
|
|
|
|
|
const wrapper = render([source], { draft: state, total: 21 })
|
|
|
|
|
await select(wrapper)
|
|
|
|
|
await wrapper.get('[aria-label="候选货物分页"] button.btn-next').trigger('click')
|
|
|
|
|
expect(wrapper.emitted('changePage')).toEqual([[2]])
|
|
|
|
|
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
|
|
|
|
|
await wrapper.setProps({ page: 2, candidates: [candidate({ orderNo: 'NEXT-PAGE', palletNo: 'NEXT-PALLET' })] })
|
2026-10-21 15:10:54 +08:00
|
|
|
expect(wrapper.get('[aria-label="待排订单"]').text()).not.toContain('货物分页,同一订单可能分布在多页')
|
2026-10-21 13:22:42 +08:00
|
|
|
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
|
|
|
|
|
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('restores the chosen order after a same-page loading refresh but discards its old drag reference', async () => {
|
|
|
|
|
const source = candidate(), wrapper = render([source]), dataTransfer = transfer()
|
|
|
|
|
await select(wrapper); await pallet(wrapper).trigger('dragstart', { dataTransfer })
|
|
|
|
|
await wrapper.setProps({ candidates: [] })
|
|
|
|
|
await wrapper.setProps({ candidates: [clone(source)] })
|
|
|
|
|
expect(pallet(wrapper).text()).toContain('PALLET-1')
|
|
|
|
|
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop', { dataTransfer })
|
|
|
|
|
expect(wrapper.emitted('add')).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('counts distinct planned groups separately when their display pallet labels are the same', async () => {
|
|
|
|
|
const first = candidate({ selection: { ...candidate().selection, warehousePalletId: null } })
|
|
|
|
|
const second = candidate({ sourceKey: 'source-2', selection: { ...first.selection, planPalletId: '12' } })
|
|
|
|
|
const wrapper = render([first, second])
|
|
|
|
|
const order = wrapper.get('[aria-label="待排订单"] tbody tr')
|
|
|
|
|
expect(order.findAll('td')[3]!.text()).toBe('2')
|
|
|
|
|
await select(wrapper)
|
2026-10-21 14:30:33 +08:00
|
|
|
expect(wrapper.findAll('[aria-label="托盘明细"] tr[draggable]')).toHaveLength(2)
|
2026-10-21 13:22:42 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses independent vehicle radio groups when two entrypoint dialogs are mounted', () => {
|
|
|
|
|
const props = { candidates: [candidate()], draft: draft(), activeContainerId: 'car-1', capacity: 30, disabled: false, warehouseSelected: true, page: 1, size: 20, total: 1 }
|
|
|
|
|
const host = mount(defineComponent({ setup: () => () => h('div', [h(SmartDispatchCargoBoard, props), h(SmartDispatchCargoBoard, props)]) }), { global: { plugins: [ElementPlus] } })
|
|
|
|
|
const [first, second] = host.findAllComponents(SmartDispatchCargoBoard)
|
|
|
|
|
expect(first!.get('input[type="radio"]').attributes('name')).not.toBe(second!.get('input[type="radio"]').attributes('name'))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts an existing dispatch slot as the exact drop target without draft containers', async () => {
|
|
|
|
|
const source = candidate()
|
|
|
|
|
const wrapper = mount(SmartDispatchCargoBoard, {
|
|
|
|
|
props: { candidates: [source], draft: null, activeContainerId: 'PC/1', capacity: 0, disabled: false, warehouseSelected: true, page: 1, size: 20, total: 1 },
|
|
|
|
|
slots: { vehicles: ({ dragOver, drop }: { dragOver: (event: DragEvent, id: string) => void; drop: (event: DragEvent, id: string) => void }) => h('section', { 'aria-label': '真实本车清单', onDragover: (event: DragEvent) => dragOver(event, 'PC/1'), onDrop: (event: DragEvent) => drop(event, 'PC/1') }, 'PALLET-EXISTING') },
|
|
|
|
|
global: { plugins: [ElementPlus] },
|
|
|
|
|
})
|
2026-10-21 15:10:54 +08:00
|
|
|
expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(true)
|
|
|
|
|
expect(wrapper.get('[aria-label="车辆装载"]').text()).not.toContain('添加车辆')
|
2026-10-21 13:22:42 +08:00
|
|
|
expect(wrapper.text()).toContain('PALLET-EXISTING')
|
|
|
|
|
await select(wrapper)
|
|
|
|
|
const dataTransfer = transfer()
|
|
|
|
|
await pallet(wrapper).trigger('dragstart', { dataTransfer })
|
|
|
|
|
await wrapper.get('[aria-label="真实本车清单"]').trigger('dragover', { dataTransfer })
|
|
|
|
|
await wrapper.get('[aria-label="真实本车清单"]').trigger('drop', { dataTransfer })
|
|
|
|
|
expect(wrapper.emitted('add')).toEqual([[source, 'PC/1']])
|
|
|
|
|
await pallet(wrapper).trigger('dragstart', { dataTransfer })
|
|
|
|
|
await wrapper.setProps({ activeContainerId: 'PC/2' })
|
|
|
|
|
await wrapper.get('[aria-label="真实本车清单"]').trigger('drop', { dataTransfer })
|
|
|
|
|
expect(wrapper.emitted('add')).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
})
|