Files
admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts
T
2026-09-11 16:17:21 +08:00

944 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ElementPlus from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import {
confirmWeightResult,
correctWeightResult,
createWeightResult,
deleteWeightResult,
downloadWeightImage,
downloadWeightThumbnail,
getWeightWorkspace,
getWeightPalletOptions,
uploadWeightImage,
} from '../api/weightOcr'
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
import WeightOcrPanel from './WeightOcrPanel.vue'
vi.mock('../api/weightOcr', async () => {
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
return {
...actual,
confirmWeightResult: vi.fn(),
correctWeightResult: vi.fn(),
createWeightResult: vi.fn(),
deleteWeightResult: vi.fn(),
downloadWeightImage: vi.fn(),
downloadWeightThumbnail: vi.fn(),
getWeightWorkspace: vi.fn(),
getWeightPalletOptions: vi.fn(),
uploadWeightImage: vi.fn(),
}
})
const pendingWeight: WeightResultResource = {
id: '30001',
fileId: '40001',
palletId: '50001',
palletCode: 'TP-001',
palletType: '木托盘',
palletCapacityCbm: 1.2,
palletTareWeightKg: 12,
ocrProvider: 'FILE_WEIGHT_OCR',
ocrRawText: '285.5',
ocrWeightValue: 285.5,
ocrWeightUnit: 'KG',
confidence: 0.98,
adjustedWeightValue: null,
adjustedWeightUnit: null,
grossWeightKg: 285.5,
netWeightKg: null,
recognitionStatus: 'PENDING_CONFIRM',
adjustmentReason: null,
adjustedBy: null,
adjustedAt: null,
confirmedBy: null,
confirmedAt: null,
palletSequence: 1,
blockId: '70001',
slotNo: 1,
reservationId: '71001',
locationId: '72001',
locationCode: 'A01',
sharedOverflow: false,
slotStatus: 'BOUND',
version: 0,
}
const workspace: WeightWorkspaceResource = {
inboundId: '20001',
inboundCode: 'RK-20001',
processStatus: 'MEASURING',
actualGrossWeightKg: null,
actualNetWeightKg: null,
weightResults: [pendingWeight],
palletLocationGuide: {
blockId: '70001',
reservedSlotCount: 1,
actualPalletCount: 1,
reservedEmptySlotCount: 0,
slots: [{
slotNo: 1,
reservationId: '71001',
locationId: '72001',
locationCode: 'A01',
sharedOverflow: false,
slotStatus: 'BOUND',
boundWeightResultId: '30001',
palletSequence: 1,
palletId: '50001',
palletCode: 'TP-001',
}],
},
}
function palletPage(records = [{
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
}]) {
return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 }
}
enableAutoUnmount(afterEach)
const allWeightPermissions = [
'platform:firstleg:inbound:measure:view',
'platform:firstleg:inbound:weight:edit',
'platform:firstleg:inbound:weight:confirm',
]
function mountPanel(permissions = allWeightPermissions, props: Record<string, unknown> = {}) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
return mount(WeightOcrPanel, {
attachTo: document.body,
props: { customerId: '10001', inboundId: '20001', ...props },
global: { plugins: [pinia, ElementPlus] },
})
}
async function openWeightDialog(wrapper: VueWrapper) {
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
await flushPromises()
}
function dialogWeightInput() {
const input = document.body.querySelector<HTMLInputElement>('input[aria-label="当前图片确认重量"]')
if (!input) throw new Error('weight confirmation input is missing')
return new DOMWrapper(input)
}
function dialogUnit(index: number) {
const option = document.body.querySelectorAll<HTMLElement>('.el-dialog .el-segmented__item').item(index)
return new DOMWrapper(option)
}
function dialogConfirmButton(text = '确认重量') {
const button = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')]
.find((candidate) => candidate.textContent?.trim() === text)
if (!button) throw new Error('weight confirmation button is missing')
return new DOMWrapper(button)
}
describe('WeightOcrPanel confirmation behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
vi.mocked(getWeightWorkspace).mockResolvedValue(workspace)
vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage())
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:test-image'),
revokeObjectURL: vi.fn(),
})
vi.mocked(confirmWeightResult).mockResolvedValue({
...pendingWeight,
recognitionStatus: 'CONFIRMED',
grossWeightKg: 297.5,
netWeightKg: 285.5,
version: 1,
})
})
it('opens the editable confirmation dialog immediately after upload', async () => {
vi.mocked(uploadWeightImage).mockResolvedValue({
fileId: '40002',
fileName: 'weight.jpg',
contentType: 'image/jpeg',
sizeBytes: 100,
recognitionStatus: 'SUCCESS',
weightValue: 12.5,
weightUnit: 'KG',
confidence: 0.98,
error: null,
})
vi.mocked(createWeightResult).mockResolvedValue({
...pendingWeight,
id: '30002',
fileId: '40002',
ocrWeightValue: 12.5,
})
const wrapper = mountPanel()
await flushPromises()
await wrapper.find('.pallet-picker .el-select').trigger('click')
expect(document.body.textContent).toContain('木托盘 · 皮重 12.000 KG · 容量 1.2 m³')
await new DOMWrapper(document.body).find('.el-select-dropdown__item').trigger('click')
const input = wrapper.get('input[type="file"]')
const file = new File(['image'], 'weight.jpg', { type: 'image/jpeg' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
await flushPromises()
expect(uploadWeightImage).toHaveBeenCalledWith('10001', '20001', file)
expect(createWeightResult).toHaveBeenCalledWith('10001', '20001', expect.objectContaining({ palletId: '50001' }))
expect(document.body.textContent).toContain('确认称重图片')
expect(document.body.textContent).toContain('当前托盘皮重')
expect(document.body.textContent).toContain('OCR 识别毛重')
expect(document.body.textContent).toContain('12.500 KG')
expect(document.body.querySelector('input[aria-label="当前图片确认重量"]')).not.toBeNull()
})
it('renders thirty compact image cards without technical OCR details', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: Array.from({ length: 30 }, (_, index) => ({
...pendingWeight,
id: String(30001 + index),
fileId: String(40001 + index),
ocrRawText: `raw-${index}`,
})),
})
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.findAll('.weight-card')).toHaveLength(30)
expect(wrapper.findAll('.weight-thumb')).toHaveLength(30)
expect(wrapper.text()).not.toContain('置信度')
expect(wrapper.text()).not.toContain('OCR 原文')
expect(wrapper.text()).not.toContain('FILE_WEIGHT_OCR')
expect(wrapper.text()).not.toContain('raw-0')
})
it('renders the server-bound location and status once in the compact card facts', async () => {
const wrapper = mountPanel()
await flushPromises()
const card = wrapper.get('.weight-card')
expect(card.text()).toContain('库位A01 · 待上架')
expect(card.text()).toContain('待上架')
expect(card.find('.weight-card__putaway').exists()).toBe(false)
expect(card.get('.weight-card__facts').text()).toContain('库位A01 · 待上架')
})
it('keeps whole-inbound reservation guidance out of the panel and shows location on each weight card', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
palletLocationGuide: {
blockId: '70001',
reservedSlotCount: 3,
actualPalletCount: 2,
reservedEmptySlotCount: 1,
slots: [
{ slotNo: 1, reservationId: '71001', locationId: '72001', locationCode: 'A01', sharedOverflow: false, slotStatus: 'BOUND', boundWeightResultId: '30001', palletSequence: 1, palletId: '50001', palletCode: 'TP-001' },
{ slotNo: 2, reservationId: '71002', locationId: '72002', locationCode: 'A02', sharedOverflow: false, slotStatus: 'TRANSFERRED', boundWeightResultId: '30002', palletSequence: 2, palletId: '50002', palletCode: 'TP-002' },
{ slotNo: 3, reservationId: '71003', locationId: '72003', locationCode: 'A03', sharedOverflow: false, slotStatus: 'RESERVED_EMPTY', boundWeightResultId: null, palletSequence: null, palletId: null, palletCode: null },
],
},
})
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.find('.putaway-guide').exists()).toBe(false)
expect(wrapper.text()).not.toContain('整票上架引导')
expect(wrapper.get('.weight-card__facts').text()).toContain('库位A01 · 待上架')
})
it('does not render a full-width placeholder when no whole-inbound guide exists', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, palletLocationGuide: null })
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).not.toContain('尚未分配整票预留库位')
expect(wrapper.find('.putaway-guide-empty').exists()).toBe(false)
})
it('marks A0 as a temporary overflow location from the server flag', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{ ...pendingWeight, locationCode: 'A0', sharedOverflow: true }],
palletLocationGuide: {
...workspace.palletLocationGuide!,
slots: [{
...workspace.palletLocationGuide!.slots[0]!,
locationCode: 'A0',
sharedOverflow: true,
}],
},
})
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).toContain('A0 临时溢出位')
expect(wrapper.get('.weight-card__facts').text()).toContain('库位A0 · 待上架 · A0 临时溢出位')
expect(wrapper.find('.putaway-guide').exists()).toBe(false)
})
it.each([
['TRANSFERRED', '已上架'],
['RELEASED', '已释放'],
['RESERVED_EMPTY', '预留空位'],
] as const)('renders the authoritative %s slot status as %s', async (slotStatus, label) => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{ ...pendingWeight, slotStatus }],
})
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).toContain(label)
})
it('shows an explicit unassigned state when the server has not bound a slot', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{
...pendingWeight,
palletSequence: null,
blockId: null,
slotNo: null,
reservationId: null,
locationId: null,
locationCode: null,
sharedOverflow: null,
slotStatus: null,
}],
})
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).not.toContain('托盘未分配')
expect(wrapper.get('.weight-card__facts').text()).toContain('库位未分配')
})
it('deletes a pending image with its optimistic-lock version', async () => {
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
vi.mocked(deleteWeightResult).mockResolvedValue(undefined)
vi.mocked(getWeightWorkspace)
.mockResolvedValueOnce(workspace)
.mockResolvedValueOnce({
...workspace,
weightResults: [],
palletLocationGuide: {
...workspace.palletLocationGuide!,
actualPalletCount: 0,
reservedEmptySlotCount: 1,
slots: [{
...workspace.palletLocationGuide!.slots[0]!,
slotStatus: 'RESERVED_EMPTY',
boundWeightResultId: null,
palletSequence: null,
palletId: null,
palletCode: null,
}],
},
})
const wrapper = mountPanel()
await flushPromises()
await wrapper.get('button[aria-label="删除称重图片 1"]').trigger('click')
await flushPromises()
expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0)
expect(confirm).toHaveBeenCalledWith(
expect.stringContaining('对应库位仍为本票保留,不会释放给其他订单'),
'删除称重图片',
expect.any(Object),
)
expect(getWeightWorkspace).toHaveBeenCalledTimes(2)
expect(wrapper.findAll('.weight-card')).toHaveLength(0)
expect(wrapper.text()).toContain('尚未上传称重图片')
expect(wrapper.find('.putaway-guide').exists()).toBe(false)
})
it('keeps the image and server workspace visible when deletion fails', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const errorMessage = vi.spyOn(ElMessage, 'error')
vi.mocked(deleteWeightResult).mockRejectedValueOnce(
new ApiResponseError('无权删除当前称重图片', 'A0403', 403),
)
const wrapper = mountPanel()
await flushPromises()
await wrapper.get('button[aria-label="删除称重图片 1"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('.weight-card')).toHaveLength(1)
expect(wrapper.text()).toContain('A01')
expect(getWeightWorkspace).toHaveBeenCalledTimes(1)
expect(errorMessage).toHaveBeenCalledWith(expect.objectContaining({
message: '当前账号无权访问该数据,请联系管理员确认权限',
}))
})
it('shows a retryable workspace load failure without fabricating putaway guidance', async () => {
vi.mocked(getWeightWorkspace).mockRejectedValueOnce(
new ApiResponseError('称重工作区暂不可用', 'A0500', 503),
)
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).toContain('称重数据加载失败')
expect(wrapper.text()).toContain('称重工作区暂不可用')
expect(wrapper.find('.putaway-guide').exists()).toBe(false)
expect(wrapper.find('.putaway-guide-empty').exists()).toBe(false)
})
it('shows only the key weights and allows direct confirmation', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
const dialogText = document.body.querySelector('.el-dialog')?.textContent || ''
expect(dialogText).toContain('当前托盘皮重')
expect(dialogText).toContain('12.000 KG')
expect(dialogText).toContain('OCR 识别毛重')
expect(dialogText).toContain('285.500 KG')
expect(dialogText).toContain('确认含托盘毛重')
expect(dialogText).toContain('托盘类型')
expect(dialogText).toContain('木托盘')
expect(dialogText).not.toContain('TP-001')
for (const hiddenLabel of ['托盘编码', '托盘容量', '识别状态', '置信度', '服务端毛重', '服务端净重', 'OCR 原文']) {
expect(dialogText).not.toContain(hiddenLabel)
}
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
expect(correctWeightResult).not.toHaveBeenCalled()
})
it('emits the latest server total after confirming a weight', async () => {
vi.mocked(getWeightWorkspace)
.mockResolvedValueOnce(workspace)
.mockResolvedValueOnce({
...workspace,
actualGrossWeightKg: 1787.5,
actualNetWeightKg: 1747.5,
weightResults: [{ ...pendingWeight, recognitionStatus: 'CONFIRMED', grossWeightKg: 297.5, netWeightKg: 285.5, version: 1 }],
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5, true]])
expect(wrapper.emitted('changed')).toHaveLength(1)
expect(wrapper.text()).toContain('实测毛重')
expect(wrapper.text()).toContain('货物重量')
expect(wrapper.text()).toContain('1787.500 KG')
expect(wrapper.text()).toContain('1747.500 KG')
})
it('atomically corrects and confirms an approved weight during measurement review', async () => {
const confirmed = {
...pendingWeight,
recognitionStatus: 'CONFIRMED',
adjustedWeightValue: 120,
adjustedWeightUnit: 'KG',
grossWeightKg: 120,
netWeightKg: 108,
version: 1,
}
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
processStatus: 'MEASURE_PENDING',
actualGrossWeightKg: 120,
actualNetWeightKg: 108,
weightResults: [confirmed],
})
vi.mocked(confirmWeightResult).mockResolvedValue({ ...confirmed, grossWeightKg: 125, netWeightKg: 113, version: 2 })
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true })
await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('删除图片')
await wrapper.findAll('button').find((button) => button.text().includes('修改含托盘毛重'))?.trigger('click')
await flushPromises()
await dialogWeightInput().setValue('125')
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
expect(reason).not.toBeNull()
await new DOMWrapper(reason!).setValue('复核磅秤读数')
await dialogConfirmButton('保存重量修正').trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 1,
adjustedWeightValue: 125,
adjustedWeightUnit: 'KG',
adjustmentReason: '复核磅秤读数',
})
expect(correctWeightResult).not.toHaveBeenCalled()
})
it('closes the confirmation dialog immediately after a successful confirmation', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
})
it('requires a pallet selection before opening the file picker', async () => {
const wrapper = mountPanel()
await flushPromises()
const click = vi.spyOn(wrapper.get('input[type="file"]').element as HTMLInputElement, 'click')
await wrapper.findAll('button').find((button) => button.text().includes('上传称重图片'))?.trigger('click')
expect(click).not.toHaveBeenCalled()
})
it('shows an actionable empty state when the warehouse has no pallet options', async () => {
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce(palletPage([]))
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).toContain('当前仓库暂无可选托盘')
expect(wrapper.findAll('button').some((button) => button.text().includes('重新加载'))).toBe(true)
})
it('renders records from the formal paged response without showing a load failure', async () => {
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce({
records: [
{ palletId: '2092803000000000001', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 },
{ palletId: '2092803000000000002', palletCode: 'TP-12', palletType: '塑料托盘', capacityCbm: 1.1, tareWeightKg: 10 },
],
page: 1,
size: 20,
total: 2,
pages: 1,
})
const wrapper = mountPanel()
await flushPromises()
await wrapper.find('.pallet-picker .el-select').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('木托盘 · 皮重 12.000 KG · 容量 1.2 m³')
expect(document.body.textContent).toContain('塑料托盘 · 皮重 10.000 KG · 容量 1.1 m³')
expect(wrapper.text()).not.toContain('可选托盘加载失败')
})
it('remote search keyword uses page 1 and size 50', async () => {
const wrapper = mountPanel()
await flushPromises()
vi.useFakeTimers()
try {
await wrapper.find('.pallet-picker .el-select').trigger('click')
await wrapper.find('.pallet-picker input').setValue(' 木托盘 ')
await vi.advanceTimersByTimeAsync(350)
await flushPromises()
expect(getWeightPalletOptions).toHaveBeenLastCalledWith('10001', '20001', expect.objectContaining({
page: 1, size: 50, keyword: '木托盘', signal: expect.any(AbortSignal),
}))
} finally {
vi.useRealTimers()
}
})
it('shows pallet loading failures without hiding existing weight results', async () => {
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503))
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).toContain('托盘服务暂不可用')
expect(wrapper.findAll('.weight-card')).toHaveLength(1)
})
it('shows a network error as a pallet candidate load failure without hiding historical snapshots', async () => {
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new AxiosError('Network Error', 'ERR_NETWORK'))
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).toContain('可选托盘加载失败')
expect(wrapper.findAll('.weight-card')).toHaveLength(1)
expect(wrapper.text()).toContain('木托盘')
expect(wrapper.text()).toContain('12.000 KG')
})
it('does not load weight or pallet data without measurement view permission', async () => {
const wrapper = mountPanel([])
await flushPromises()
expect(wrapper.text()).toContain('缺少入库测量查看权限')
expect(getWeightWorkspace).not.toHaveBeenCalled()
expect(getWeightPalletOptions).not.toHaveBeenCalled()
})
it('keeps historical palletCode snapshots visible for a read-only operator', async () => {
const wrapper = mountPanel(['platform:firstleg:inbound:measure:view'])
await flushPromises()
expect(getWeightWorkspace).toHaveBeenCalledOnce()
expect(getWeightPalletOptions).not.toHaveBeenCalled()
expect(wrapper.find('.pallet-picker .el-select').exists()).toBe(false)
expect(wrapper.text()).toContain('TP-001')
expect(wrapper.text()).toContain('木托盘')
expect(wrapper.text()).toContain('12.000 KG')
})
it('does not request pallet candidates when the inbound status is not editable', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, processStatus: 'MEASURE_PENDING' })
mountPanel()
await flushPromises()
expect(getWeightPalletOptions).not.toHaveBeenCalled()
})
it('virtualizes one thousand pallet candidates instead of rendering one thousand option nodes', async () => {
vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage(Array.from({ length: 1_000 }, (_, index) => ({
palletId: String(60_000 + index),
palletCode: `TP-${index}`,
palletType: `托盘类型 ${index}`,
capacityCbm: 1.2,
tareWeightKg: 12,
}))))
const wrapper = mountPanel()
await flushPromises()
await wrapper.find('.pallet-picker .el-select').trigger('click')
await flushPromises()
const renderedOptions = document.body.querySelectorAll('.el-select-dropdown__item')
expect(renderedOptions.length).toBeGreaterThan(0)
expect(renderedOptions.length).toBeLessThan(100)
})
it('aborts an in-flight pallet request and ignores its late result when the panel closes', async () => {
let resolveOptions: ((value: Awaited<ReturnType<typeof getWeightPalletOptions>>) => void) | undefined
vi.mocked(getWeightPalletOptions).mockImplementationOnce(() => new Promise((resolve) => {
resolveOptions = resolve
}))
const wrapper = mountPanel()
await flushPromises()
const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]?.signal
wrapper.unmount()
resolveOptions?.(palletPage([{
palletId: 'late', palletCode: 'TP-LATE', palletType: '过期托盘', capacityCbm: 1, tareWeightKg: 10,
}]))
await flushPromises()
expect(signal?.aborted).toBe(true)
expect(document.body.textContent).not.toContain('过期托盘')
})
it('keeps a low-confidence OCR result focused on the editable weight', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{ ...pendingWeight, ocrRawText: '5360', ocrWeightValue: 5360, confidence: 0.51863 }],
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
const dialogText = document.body.textContent || ''
expect(dialogText).toContain('OCR 识别毛重')
expect(dialogText).toContain('5360.000 KG')
expect(dialogText).toContain('确认含托盘毛重')
expect(dialogText).not.toContain('置信度')
expect(dialogText).not.toContain('OCR 原文')
expect(dialogText).not.toContain('OCR 可能误读数字或小数点')
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
})
it('sends the adjusted value and a specific audit reason in the same confirmation request', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogWeightInput().setValue('286.125')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 286.125,
adjustedWeightUnit: 'KG',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 286.125 KG',
})
expect(correctWeightResult).not.toHaveBeenCalled()
})
it('sends an LB adjustment atomically after switching units', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogWeightInput().setValue('30.125')
await dialogUnit(1).trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 30.125,
adjustedWeightUnit: 'LB',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 30.125 LB',
})
expect(correctWeightResult).not.toHaveBeenCalled()
})
it('rejects an LB value that would round to zero kilograms', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogWeightInput().setValue('0.001')
await dialogUnit(1).trigger('click')
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
expect(confirmWeightResult).not.toHaveBeenCalled()
})
it('rejects a positive gross weight that does not exceed the pallet tare', async () => {
const warning = vi.spyOn(ElMessage, 'warning')
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogWeightInput().setValue('0.002')
await dialogUnit(1).trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).not.toHaveBeenCalled()
expect(warning).toHaveBeenCalledWith('称重毛重必须大于托盘皮重 12.000 KG')
})
it('clamps a value outside the safe three-decimal JavaScript range', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
const input = dialogWeightInput()
await input.setValue('9000000000001')
expect((input.element as HTMLInputElement).value).toBe('9000000000000.000')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 9_000_000_000_000,
adjustedWeightUnit: 'KG',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 9000000000000.000 KG',
})
})
it('rounds an input with more than three decimals before confirmation', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
const input = dialogWeightInput()
await input.setValue('13.2345')
expect((input.element as HTMLInputElement).value).toBe('13.235')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 13.235,
adjustedWeightUnit: 'KG',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 13.235 KG',
})
})
it('confirms an existing corrected value without creating another correction', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{
...pendingWeight,
adjustedWeightValue: 286.125,
adjustedWeightUnit: 'KG',
adjustmentReason: '首次修正',
version: 1,
}],
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 1 })
expect(correctWeightResult).not.toHaveBeenCalled()
})
it('allows a confirm-only operator to accept an unchanged OCR value', async () => {
const wrapper = mountPanel([
'platform:firstleg:inbound:measure:view',
'platform:firstleg:inbound:weight:confirm',
])
await flushPromises()
await openWeightDialog(wrapper)
expect(dialogWeightInput().attributes('disabled')).toBeDefined()
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
})
it('does not render confirmation for an edit-only operator', async () => {
const wrapper = mountPanel([
'platform:firstleg:inbound:measure:view',
'platform:firstleg:inbound:weight:edit',
])
await flushPromises()
await openWeightDialog(wrapper)
const confirmButtons = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')]
.filter((button) => button.textContent?.trim() === '确认重量')
expect(confirmButtons).toHaveLength(0)
expect(confirmWeightResult).not.toHaveBeenCalled()
})
it('supports manual entry when OCR did not return a value', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{
...pendingWeight,
ocrRawText: null,
ocrWeightValue: null,
ocrWeightUnit: null,
recognitionStatus: 'NEEDS_MANUAL',
}],
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
await dialogWeightInput().setValue('13.500')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 13.5,
adjustedWeightUnit: 'KG',
adjustmentReason: 'OCR 原值 —,人工确认 13.500 KG',
})
})
it('normalizes a supported OCR unit alias with surrounding whitespace', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{ ...pendingWeight, ocrWeightUnit: ' lbs ' }],
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
})
it('sends an explicit correction when OCR returns an unsupported unit', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{ ...pendingWeight, ocrWeightUnit: 'G' }],
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 285.5,
adjustedWeightUnit: 'KG',
adjustmentReason: 'OCR 原值 285.500 G,人工确认 285.500 KG',
})
})
it('sends an explicit correction when OCR returns a value without a unit', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{ ...pendingWeight, ocrWeightUnit: null }],
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 285.5,
adjustedWeightUnit: 'KG',
adjustmentReason: 'OCR 原值 285.500(单位缺失),人工确认 285.500 KG',
})
})
it('keeps the edited draft available when confirmation fails', async () => {
const errorMessage = vi.spyOn(ElMessage, 'error')
vi.mocked(confirmWeightResult).mockRejectedValueOnce(
new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 409, undefined, 'request-1'),
)
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
const input = dialogWeightInput()
await input.setValue('286.125')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect((input.element as HTMLInputElement).value).toBe('286.125')
expect(wrapper.text()).not.toContain('操作未完成')
expect(errorMessage).toHaveBeenCalledWith(expect.objectContaining({
message: '数据版本已变化,请刷新后重试(请求 ID:request-1)',
duration: 5_000,
showClose: true,
}))
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
})
})