149 lines
12 KiB
JavaScript
149 lines
12 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { loadingPhotoLabels, loadingPhotoLimit, loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
|
|
import { session } from '../src/stores/session.js'
|
|
const source = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
|
|
const data = extra => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
|
const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.png', contentType: 'image/png' }
|
|
function component(overrides = {}) {
|
|
const values = new Map(), events = []
|
|
globalThis.uni = { setStorageSync: (key, value) => values.set(key, value), removeStorageSync: key => values.delete(key), getStorageSync: key => values.get(key), chooseImage: async () => ({ tempFilePaths: ['local-image'], tempFiles: [{ size: 123 }] }), showToast() {} }
|
|
const dependencies = { session: { currentUser: { id: 'user', warehouseId: 'warehouse' } }, loadingPhotoSlots, loadingPhotoLabels, loadingPhotoLimit, toDispatchError: cause => cause,
|
|
getLoadingDocuments: async () => data(), uploadLoadingPhoto: async () => photo, recoverLoadingPhoto: async () => photo,
|
|
bindLoadingPhoto: async () => data({ recordVersion: '1', photos: [photo] }), readLoadingPhoto: async () => 'downloaded-image', ...overrides }
|
|
const definition = new Function(...Object.keys(dependencies), source.replace(/^import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
|
|
const state = { ...definition.data.call({ dispatchNo: 'PC/1' }), dispatchNo: 'PC/1', $emit: (...args) => events.push(args) }
|
|
for (const [key, method] of Object.entries(definition.methods)) state[key] = method.bind(state)
|
|
for (const [key, getter] of Object.entries(definition.computed)) Object.defineProperty(state, key, { get: () => getter.call(state) })
|
|
return { state, events, values, definition }
|
|
}
|
|
|
|
test('five existing slots carry their business names into upload, replace and preview controls', async () => {
|
|
const { state } = component()
|
|
assert.deepEqual(state.slots, ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'])
|
|
assert.deepEqual(state.slotLabels, { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' })
|
|
const template = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).split('<script>')[0]
|
|
assert.match(template, /class="photo-label">\{\{ slotLabels\[slot\] \}\}/)
|
|
assert.match(template, /@tap="choose\(slot, photo.fileId\)"/)
|
|
assert.match(template, /:aria-label="`预览\$\{slotLabels\[slot\]\}`"/)
|
|
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
|
|
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
|
|
})
|
|
test('PDA reads and binds through its own namespace without customer or warehouse input', async () => {
|
|
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
|
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
|
|
await getLoadingDocuments('PC/1')
|
|
const body = { recordVersion: '9007199254740993', idempotencyKey: 'stable-key', fileId: '1001' }
|
|
await bindLoadingPhoto('PC/1', 'LOADING_SCENE', body)
|
|
assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC%2F1\/loading-documents$/)
|
|
assert.equal(calls[1].method, 'PUT'); assert.deepEqual(calls[1].data, body)
|
|
})
|
|
test('PDA upload preserves the original request key through the authenticated upload helper', async () => {
|
|
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
|
globalThis.uni = { uploadFile(options) { calls.push(options); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: photo }) }) } }
|
|
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key')
|
|
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key$/)
|
|
assert.equal(calls[0].filePath, 'local-image')
|
|
})
|
|
|
|
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
|
|
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
|
|
assert.throws(() => normalizeLoadingDocuments(data({ photos: [photo, photo] }), 'PC/1'), /重复/)
|
|
assert.throws(() => normalizeLoadingDocuments(data({ recordVersion: null }), 'PC/1'), /不一致/)
|
|
})
|
|
|
|
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0422', message: 'invalid' }]) test('upload failure leaves saved photos and unlocks current selection: ' + failure.message, async () => {
|
|
const uploads = []; let reads = 0, choice = 0
|
|
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ photos: [photo] }) }, uploadLoadingPhoto: async (...args) => { uploads.push(args); throw failure } })
|
|
uni.chooseImage = async () => ({ tempFilePaths: ['image-' + ++choice] })
|
|
await state.load(); await state.choose(photo.slot)
|
|
assert.equal(state.busy, false); assert.equal(values.size, 0); assert.equal(reads, 1)
|
|
assert.equal(state.snapshot.photos[0].fileId, photo.fileId)
|
|
await state.choose(photo.slot)
|
|
assert.equal(uploads.length, 2); assert.equal(uploads[1][2], 'image-2'); assert.notEqual(uploads[0][3], uploads[1][3])
|
|
assert.deepEqual(events.at(-1), ['busy-change', false])
|
|
})
|
|
test('binding failure releases immediately and the next selection uses a new file, key and current version', async () => {
|
|
const binds = []; let uploads = 0, version = '0', reads = 0
|
|
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ recordVersion: version }) }, uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++uploads) }), bindLoadingPhoto: async (_no, _slot, body) => { binds.push(body); if (binds.length === 1) throw { network: true }; return data({ photos: [photo] }) } })
|
|
await state.load(); await state.choose(photo.slot)
|
|
assert.equal(state.busy, false); assert.equal(reads, 3); assert.equal(values.size, 0)
|
|
version = '7'; await state.choose(photo.slot)
|
|
assert.equal(uploads, 2); assert.equal(binds[1].recordVersion, '7'); assert.notEqual(binds[1].fileId, binds[0].fileId)
|
|
assert.notEqual(binds[1].idempotencyKey, binds[0].idempotencyKey); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
|
})
|
|
test('a lost binding response immediately reloads server success without another write', async () => {
|
|
let binds = 0, latest = data()
|
|
const { state } = component({ getLoadingDocuments: async () => latest, bindLoadingPhoto: async () => { binds++; latest = data({ editable: false, complete: true, stage: 'DELIVERING', photos: [photo] }); throw { network: true } } })
|
|
await state.load(); await state.choose(photo.slot); assert.equal(state.busy, false)
|
|
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(state.editable, false)
|
|
await state.load(); assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(binds, 1); assert.equal(state.editable, false)
|
|
})
|
|
|
|
test('failed reconciliation blocks further uploads until an explicit refresh succeeds', async () => {
|
|
let reads = 0, uploads = 0
|
|
const { state } = component({ getLoadingDocuments: async () => { if (++reads === 3) throw new Error('读取失败'); return data() },
|
|
uploadLoadingPhoto: async () => { uploads++; return photo }, bindLoadingPhoto: async () => { throw new Error('保存超时') } })
|
|
await state.load(); await state.choose(photo.slot)
|
|
assert.equal(state.busy, false); assert.equal(state.needsRefresh, true); assert.equal(state.editable, false)
|
|
assert.match(state.error.message, /保存超时/)
|
|
await state.choose(photo.slot); assert.equal(uploads, 1)
|
|
await state.load(); assert.equal(state.needsRefresh, false); assert.equal(state.editable, true)
|
|
})
|
|
test('in-flight upload blocks duplicate clicks and unmount releases the parent busy flag', async () => {
|
|
let resolveUpload, uploads = 0
|
|
const { state, events, definition } = component({ uploadLoadingPhoto: () => { uploads++; return new Promise(resolve => { resolveUpload = resolve }) } })
|
|
await state.load(); const first = state.choose(photo.slot)
|
|
while (!resolveUpload) await new Promise(resolve => setImmediate(resolve))
|
|
await state.choose(photo.slot); assert.equal(uploads, 1)
|
|
definition.beforeUnmount.call(state); assert.deepEqual(events.at(-1), ['busy-change', false])
|
|
resolveUpload(photo); await first; assert.equal(events.some(event => event[0] === 'saved'), false)
|
|
})
|
|
|
|
test('normalization accepts five distinct photos per category but rejects a sixth', () => {
|
|
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(index + 1) }))
|
|
assert.equal(normalizeLoadingDocuments(data({ photos }), 'PC/1').photos.length, 5)
|
|
assert.throws(() => normalizeLoadingDocuments(data({ photos: [...photos, { ...photo, fileId: '6' }] }), 'PC/1'), /最多5张/)
|
|
})
|
|
|
|
test('multi-select appends every image using fresh versions and counts categories rather than files', async () => {
|
|
let latest = data(), count = 0, selectedCount
|
|
const binds = []
|
|
const { state, events } = component({ getLoadingDocuments: async () => latest,
|
|
uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++count) }),
|
|
bindLoadingPhoto: async (_no, _slot, body) => {
|
|
binds.push(body)
|
|
latest = data({ recordVersion: String(binds.length), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }); return latest
|
|
} })
|
|
uni.chooseImage = async options => { selectedCount = options.count; return { tempFilePaths: ['a', 'b', 'c', 'd', 'e'] } }
|
|
await state.load(); await state.choose(photo.slot)
|
|
assert.equal(selectedCount, 5); assert.equal(state.snapshot.photos.length, 5); assert.equal(state.completedCategories, 1)
|
|
assert.deepEqual(binds.map(body => body.recordVersion), ['0', '1', '2', '3', '4'])
|
|
assert.equal(new Set(binds.map(body => body.idempotencyKey)).size, 5)
|
|
await state.choose(photo.slot); assert.equal(count, 5)
|
|
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
|
})
|
|
|
|
test('replacement sends the selected old file and retains the other four photos', async () => {
|
|
let latest = data({ photos: Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(index + 1) })) })
|
|
let selectedCount, request
|
|
const { state } = component({ getLoadingDocuments: async () => latest, uploadLoadingPhoto: async () => ({ ...photo, fileId: '6' }),
|
|
bindLoadingPhoto: async (_no, _slot, body) => { request = body; latest = data({ recordVersion: '1', photos: latest.photos.map(item => item.fileId === body.replaceFileId ? { ...photo, fileId: body.fileId } : item) }); return latest } })
|
|
uni.chooseImage = async options => { selectedCount = options.count; return { tempFilePaths: ['replacement'] } }
|
|
await state.load(); await state.choose(photo.slot, '3')
|
|
assert.equal(selectedCount, 1); assert.equal(request.replaceFileId, '3')
|
|
assert.deepEqual(state.snapshot.photos.map(item => item.fileId), ['1', '2', '6', '4', '5'])
|
|
})
|
|
|
|
test('a partial batch failure preserves successful photos and notifies the task once', async () => {
|
|
let latest = data(), uploads = 0
|
|
const { state, events } = component({ getLoadingDocuments: async () => latest,
|
|
uploadLoadingPhoto: async () => { if (++uploads === 2) throw new Error('网络中断'); return photo },
|
|
bindLoadingPhoto: async () => { latest = data({ recordVersion: '1', photos: [photo] }); return latest } })
|
|
uni.chooseImage = async () => ({ tempFilePaths: ['first', 'failed', 'unattempted'] })
|
|
await state.load(); await state.choose(photo.slot)
|
|
assert.equal(uploads, 2); assert.equal(state.busy, false); assert.match(state.error.message, /网络中断/)
|
|
assert.deepEqual(state.snapshot.photos, [photo]); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
|
})
|