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 = (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 = {}) { 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') } function pallet(wrapper: VueWrapper) { return wrapper.get('[aria-label="托盘明细"] tr[draggable]') } 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('请选择左侧订单') expect(wrapper.get('[aria-label="托盘明细"]').findAll('thead th').map(cell => cell.text())).toEqual(['托盘号', '箱数']) 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) expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false) 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) expect(wrapper.findAll('[aria-label="托盘明细"] tr[draggable]')).toHaveLength(1) 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) expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false) } expect(wrapper.emitted('add')).toBeUndefined() }) 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('删除车辆') }) 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' })] }) expect(wrapper.get('[aria-label="待排订单"]').text()).not.toContain('货物分页,同一订单可能分布在多页') 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) expect(wrapper.findAll('[aria-label="托盘明细"] tr[draggable]')).toHaveLength(2) }) 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] }, }) expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(true) expect(wrapper.get('[aria-label="车辆装载"]').text()).not.toContain('添加车辆') 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) }) })