feat(pda): support five loading photos per category and reconcile saves
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
|
||||
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 })
|
||||
@@ -9,7 +9,7 @@ const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.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, toDispatchError: cause => cause,
|
||||
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))
|
||||
@@ -22,10 +22,10 @@ function component(overrides = {}) {
|
||||
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: '回执' })
|
||||
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, /:aria-label="`\$\{snapshot\?\.photos.some\(item => item.slot === slot\) \? '更换' : '上传'\}\$\{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 \}\}/)
|
||||
@@ -68,17 +68,29 @@ test('binding failure releases immediately and the next selection uses a new fil
|
||||
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, 2); assert.equal(values.size, 0)
|
||||
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('manual refresh after a lost binding response reflects server success without another write', async () => {
|
||||
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 }) } })
|
||||
@@ -88,3 +100,49 @@ test('in-flight upload blocks duplicate clicks and unmount releases the parent b
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user