fix(pda): 修复海外PDA真机作业并固定APK与热更新发布流程
This commit is contained in:
@@ -24,7 +24,7 @@ test('picking separates source facts, current quantity and completion while reta
|
||||
assert.match(template, /aria-label="本次拣货箱数"[^>]+:disabled="busy"[^>]+@input="updatePackages"/)
|
||||
assert.match(template, /name="dispatch"[^>]+:active="activeTab === 'picking'"[^>]+@submit="handleScan"/)
|
||||
assert.match(template, /:disabled="!canPick \|\| busy" @tap="submitPick"/)
|
||||
assert.match(template, /:disabled="!canComplete \|\| busy" @tap="prepareLoading"/)
|
||||
assert.match(template, /:disabled="!canComplete \|\| busy \|\| loading \|\| loadingPreview !== null" :loading="requestBusy" @tap="prepareLoading"/)
|
||||
assert.match(template, /@tap="selectPallet\(item\)"/)
|
||||
assert.match(page, /\.picking-fact > text:last-child \{[^}]*overflow-wrap: anywhere/)
|
||||
})
|
||||
|
||||
@@ -107,6 +107,84 @@ test('actual loading confirmation leaves reference measurements blank and blocks
|
||||
assert.equal(state.loadingRows[0].deliveryIndex, null)
|
||||
Object.assign(state.loadingRows[0], { weightKg: 92, volumeCbm: 1.5 }); await state.completeLoading(); assert.equal(posts, 0); assert.match(state.error.message, /卸货站/)
|
||||
})
|
||||
|
||||
test('loading completion opens a callback-based confirmation with pallet and package counts before submitting', async () => {
|
||||
storage(); let modal, posts = 0, reads = 0
|
||||
uni.showModal = options => { modal = options }
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'two-pallets', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [
|
||||
{ palletNo: 'PALLET-1', pickedPackages: '12', deliveryIndex: 1 },
|
||||
{ palletNo: 'PALLET-2', pickedPackages: '10', deliveryIndex: 1 }
|
||||
] }
|
||||
const state = page({ getDispatchLoading: async () => { reads++; return preview },
|
||||
completeDispatchLoading: async () => { posts++; return proof(lastCommand) },
|
||||
getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING' }) })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
|
||||
await state.prepareLoading()
|
||||
assert.equal(state.busy, false)
|
||||
assert.equal(modal.title, '确认装车')
|
||||
assert.equal(modal.content, '已拣托盘 2 托\n已拣箱数 22 箱')
|
||||
assert.equal(posts, 0)
|
||||
await state.prepareLoading()
|
||||
assert.equal(reads, 1)
|
||||
await modal.success({ confirm: true })
|
||||
assert.equal(posts, 1)
|
||||
assert.equal(state.current.stage, 'AFTER_LOADING')
|
||||
assert.equal(state.loadingPreview, null)
|
||||
})
|
||||
|
||||
test('cancelling or failing to open loading confirmation releases the preview for a fresh attempt', async () => {
|
||||
storage(); let modal, reads = 0
|
||||
uni.showModal = options => { modal = options }
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
|
||||
const state = page({ getDispatchLoading: async () => { reads++; return preview } })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
|
||||
await state.prepareLoading()
|
||||
await modal.success({ cancel: true })
|
||||
assert.equal(state.loadingPreview, null)
|
||||
await state.prepareLoading()
|
||||
modal.fail({ errMsg: '弹窗打开失败' })
|
||||
assert.equal(state.loadingPreview, null)
|
||||
assert.equal(state.error.message, '弹窗打开失败')
|
||||
await state.prepareLoading()
|
||||
assert.equal(reads, 3)
|
||||
})
|
||||
|
||||
test('loading confirmation ignores responses and callbacks after leaving the task', async () => {
|
||||
storage(); let resolve, modal, posts = 0
|
||||
uni.showModal = options => { modal = options }
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
|
||||
const state = page({ getDispatchLoading: () => new Promise(done => { resolve = done }), completeDispatchLoading: async () => { posts++ } })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
|
||||
const reading = state.prepareLoading()
|
||||
assert.equal(state.requestBusy, true)
|
||||
state.generation++
|
||||
resolve(preview)
|
||||
await reading
|
||||
assert.equal(modal, undefined)
|
||||
assert.equal(state.loadingPreview, null)
|
||||
const secondReading = state.prepareLoading()
|
||||
resolve(preview)
|
||||
await secondReading
|
||||
state.generation++
|
||||
await modal.success({ confirm: true })
|
||||
assert.equal(posts, 0)
|
||||
assert.equal(state.loadingPreview, null)
|
||||
})
|
||||
|
||||
test('failed loading submission closes the native confirmation and allows a new read', async () => {
|
||||
storage(); let modal, reads = 0
|
||||
uni.showModal = options => { modal = options }
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'manifest', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
|
||||
const state = page({ getDispatchLoading: async () => { reads++; return preview }, completeDispatchLoading: async () => { throw new Error('装车提交失败') } })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
|
||||
await state.prepareLoading()
|
||||
await modal.success({ confirm: true })
|
||||
assert.equal(state.error.message, '装车提交失败')
|
||||
assert.equal(state.loadingPreview, null)
|
||||
assert.equal(state.busy, false)
|
||||
await state.prepareLoading()
|
||||
assert.equal(reads, 2)
|
||||
})
|
||||
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
|
||||
storage(); const state = page(); state.selectPallet(state.current.items[0]); state.current.recordVersion = undefined
|
||||
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse, compileTemplate } from '@vue/compiler-sfc'
|
||||
import {
|
||||
PDA_AUTHORITY, PDA_ROLE, loadLoginPreference, saveLoginPreference,
|
||||
restoreSession, session, signInWithTokens, signOut
|
||||
} from '../src/stores/session.js'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/login/login.vue', import.meta.url), 'utf8')
|
||||
const { descriptor, errors } = parse(source)
|
||||
const preferenceKey = 'overseas-pda-login-account-preference'
|
||||
|
||||
function storage() {
|
||||
const values = new Map()
|
||||
globalThis.uni = {
|
||||
setStorageSync: (key, value) => values.set(key, JSON.parse(JSON.stringify(value))),
|
||||
getStorageSync: (key) => values.get(key),
|
||||
removeStorageSync: (key) => values.delete(key),
|
||||
reLaunch() {}
|
||||
}
|
||||
signOut()
|
||||
return values
|
||||
}
|
||||
|
||||
function tokens() {
|
||||
const claims = {
|
||||
user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM',
|
||||
overseas_warehouse_id: 900, exp: Math.floor(Date.now() / 1000) + 3600
|
||||
}
|
||||
return {
|
||||
accessToken: `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`,
|
||||
refreshToken: 'refresh-42', roles: [PDA_ROLE], authorities: [PDA_AUTHORITY]
|
||||
}
|
||||
}
|
||||
|
||||
function page(overrides = {}) {
|
||||
const dependencies = {
|
||||
DEV_LOGIN: { username: '', password: '' }, session, restoreSession, signInWithTokens,
|
||||
loadLoginPreference, saveLoginPreference, requireOnline() {},
|
||||
loginRequest: async () => tokens(), console: { error() {} }, ...overrides
|
||||
}
|
||||
const script = descriptor.script.content.replace(/^\s*import .*$/gm, '').replace('export default', 'return')
|
||||
const definition = new Function(...Object.keys(dependencies), script)(...Object.values(dependencies))
|
||||
const vm = definition.data()
|
||||
for (const [name, method] of Object.entries(definition.methods)) vm[name] = method.bind(vm)
|
||||
definition.onLoad.call(vm)
|
||||
return vm
|
||||
}
|
||||
|
||||
test('login template compiles with an account switch and separate row and switch events', () => {
|
||||
assert.deepEqual(errors, [])
|
||||
const compiled = compileTemplate({ source: descriptor.template.content, filename: 'login.vue', id: 'login' })
|
||||
assert.deepEqual(compiled.errors, [])
|
||||
assert.match(descriptor.template.content, /@tap="toggleRememberAccount"/)
|
||||
assert.match(descriptor.template.content, /<switch[^>]*:checked="rememberAccount"[^>]*@change="handleRememberChange"[^>]*@tap\.stop/)
|
||||
})
|
||||
|
||||
test('new installations default to remembering the account and an empty login form', () => {
|
||||
storage()
|
||||
assert.deepEqual(loadLoginPreference(), { rememberAccount: true, account: '' })
|
||||
const vm = page()
|
||||
assert.equal(vm.rememberAccount, true)
|
||||
assert.deepEqual(vm.form, { userId: '', password: '' })
|
||||
})
|
||||
|
||||
test('successful login remembers the trimmed account across sign out and page reopening', async () => {
|
||||
const values = storage()
|
||||
let credentials
|
||||
const vm = page({ loginRequest: async (...args) => { credentials = args; return tokens() } })
|
||||
vm.form = { userId: ' picker-42 ', password: 'test-password' }
|
||||
await vm.login()
|
||||
assert.equal(vm.error, '')
|
||||
assert.deepEqual(credentials, ['picker-42', 'test-password'])
|
||||
assert.deepEqual(values.get(preferenceKey), { rememberAccount: true, account: 'picker-42' })
|
||||
assert.equal(session.currentUser.id, '42')
|
||||
signOut()
|
||||
const reopened = page()
|
||||
assert.equal(reopened.form.userId, 'picker-42')
|
||||
assert.equal(reopened.form.password, '')
|
||||
assert.equal(reopened.rememberAccount, true)
|
||||
})
|
||||
|
||||
test('switch and row can each clear the saved account immediately without logging in', () => {
|
||||
storage()
|
||||
saveLoginPreference(true, 'picker-42')
|
||||
const vm = page()
|
||||
vm.handleRememberChange({ detail: { value: false } })
|
||||
assert.equal(vm.rememberAccount, false)
|
||||
assert.deepEqual(loadLoginPreference(), { rememberAccount: false, account: '' })
|
||||
assert.equal(page().form.userId, '')
|
||||
assert.equal(page().rememberAccount, false)
|
||||
vm.toggleRememberAccount()
|
||||
saveLoginPreference(true, 'picker-42')
|
||||
vm.toggleRememberAccount()
|
||||
assert.equal(vm.rememberAccount, false)
|
||||
assert.deepEqual(loadLoginPreference(), { rememberAccount: false, account: '' })
|
||||
})
|
||||
|
||||
test('login with remembering disabled leaves the next login form empty', async () => {
|
||||
storage()
|
||||
const vm = page()
|
||||
vm.toggleRememberAccount()
|
||||
vm.form = { userId: 'picker-42', password: 'test-password' }
|
||||
await vm.login()
|
||||
assert.equal(vm.error, '')
|
||||
assert.equal(session.currentUser.id, '42')
|
||||
signOut()
|
||||
const reopened = page()
|
||||
assert.equal(reopened.rememberAccount, false)
|
||||
assert.deepEqual(reopened.form, { userId: '', password: '' })
|
||||
})
|
||||
|
||||
test('failed authentication and rejected operator tokens preserve the last successful account', async () => {
|
||||
storage()
|
||||
saveLoginPreference(true, 'previous-picker')
|
||||
for (const loginRequest of [
|
||||
async () => { throw new Error('密码错误') },
|
||||
async () => ({ ...tokens(), authorities: [] })
|
||||
]) {
|
||||
const vm = page({ loginRequest })
|
||||
vm.form = { userId: 'other-picker', password: 'test-password' }
|
||||
await vm.login()
|
||||
assert.ok(vm.error)
|
||||
assert.equal(vm.submitting, false)
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.deepEqual(loadLoginPreference(), { rememberAccount: true, account: 'previous-picker' })
|
||||
}
|
||||
})
|
||||
@@ -54,6 +54,44 @@ test('camera resume retains all unsigned uploads by role and clears them across
|
||||
vm.suspendReceiving()
|
||||
})
|
||||
|
||||
test('camera callbacks before and after onShow keep every unsigned photo after preparing a container', async t => {
|
||||
store.resetReceivingSession()
|
||||
const current = sessionValue({ status: 'WAIT_SIGN', preparedAt: '2026-11-06T17:24:00.124', photos: [], domesticLoadingPhotos: [] })
|
||||
const { vm, definition } = page({
|
||||
prepareReceivingContainer: async () => ({ ...current, preparedAt: '2026-11-06T17:24:00.123789' }),
|
||||
getCurrentReceiving: async () => structuredClone(current),
|
||||
uploadCurrentUnloadPhoto: async path => ({ fileId: path.split(':')[1] })
|
||||
})
|
||||
t.after(() => vm.suspendReceiving())
|
||||
const previousUni = globalThis.uni
|
||||
t.after(() => { globalThis.uni = previousUni })
|
||||
vm.refreshBoundWarehouse = async () => {}
|
||||
let refresh, upload, camera
|
||||
const bootstrap = vm.bootstrap, replacePhoto = vm.replacePhoto
|
||||
vm.bootstrap = () => { refresh = bootstrap(); return refresh }
|
||||
vm.replacePhoto = (...args) => { upload = replacePhoto(...args); return upload }
|
||||
globalThis.uni = { chooseImage: options => { camera = options; definition.onHide.call(vm) } }
|
||||
store.receiving.selected = { ...current, sourceContainerBookingId: 'BOOKING-A', allPlansReady: true, operable: true }
|
||||
await vm.prepareContainer()
|
||||
vm.bootstrapped = true
|
||||
|
||||
vm.choosePhoto(0)
|
||||
camera.success({ tempFilePaths: ['camera:plate'] })
|
||||
definition.onShow.call(vm)
|
||||
await upload
|
||||
assert.equal(vm.photoSlots[0].fileId, 'plate')
|
||||
|
||||
for (const [index, role] of [[1, 'seal'], [2, 'door']]) {
|
||||
vm.choosePhoto(index)
|
||||
definition.onShow.call(vm)
|
||||
await refresh
|
||||
camera.success({ tempFilePaths: [`camera:${role}`] })
|
||||
await upload
|
||||
}
|
||||
assert.deepEqual(vm.photoSlots.map(photo => photo?.fileId), ['plate', 'seal', 'door'])
|
||||
assert.equal(vm.photosReady, true)
|
||||
})
|
||||
|
||||
test('same current readback retains three signed previews; collapsed unversioned domestic images issue no downloads', async () => {
|
||||
store.resetReceivingSession(); const calls = []
|
||||
const download = async path => { calls.push(path); return `local:${path}` }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { parse, compileStyle, compileTemplate } from '@vue/compiler-sfc'
|
||||
|
||||
const read = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8')
|
||||
const page = read('../src/pages/operation/operation.vue')
|
||||
@@ -91,10 +92,34 @@ test('domestic cabinet photos are read-only, previewable and non-blocking in sig
|
||||
})
|
||||
|
||||
test('receiving finish action remains in document flow after the scan and save controls', () => {
|
||||
assert.match(page, /v-if="canFinishOnsite" class="sticky-action-dock"><view class="sticky-action one"/)
|
||||
assert.match(page, /v-if="canFinishOnsite" class="receiving-finish-card"/)
|
||||
assert.match(page, /allInboundsReconciled \? '完成整柜入库' : '现场收货结束(标记差异)'/)
|
||||
assert.match(page, /sticky-action-dock\{position:static;max-width:none/)
|
||||
assert.match(page, /receiving-content-with-action\{[^}]*padding-bottom:calc\(24px \+ env\(safe-area-inset-bottom,0px\)\)/)
|
||||
assert.ok(page.indexOf('class="receiving-finish-card"') > page.indexOf('@tap="submitReceipt"'))
|
||||
assert.match(page, /:class="allInboundsReconciled \? 'primary-button' : 'secondary-button'"/)
|
||||
assert.match(page, /receiving-content\{[^}]*padding-bottom:calc\(24px \+ env\(safe-area-inset-bottom,0px\)\)/)
|
||||
assert.match(page, /primary-button,\.secondary-button,\.ghost-button\{[^}]*align-items:center;justify-content:center[^}]*line-height:normal;text-align:center/)
|
||||
assert.match(page, /sticky-action button\{[^}]*align-items:center;justify-content:center;line-height:normal;text-align:center/)
|
||||
const { descriptor, errors } = parse(page)
|
||||
assert.deepEqual(errors, [])
|
||||
const style = compileStyle({ source: descriptor.styles[0].content, filename: 'operation.vue', id: 'receiving', scoped: true })
|
||||
assert.deepEqual(style.errors, [])
|
||||
const dockPositions = []
|
||||
style.rawResult.root.walkRules(rule => {
|
||||
if (rule.selector.includes('.sticky-action-dock')) rule.walkDecls('position', declaration => dockPositions.push(declaration.value))
|
||||
if (rule.selector.includes('.receiving-finish-card')) rule.walkDecls('position', declaration => assert.equal(declaration.value, 'static'))
|
||||
})
|
||||
assert.deepEqual(dockPositions, ['static'])
|
||||
})
|
||||
|
||||
test('pallet quantity input reserves room above the keyboard and confirms through receipt submission', () => {
|
||||
const { descriptor, errors } = parse(page)
|
||||
assert.deepEqual(errors, [])
|
||||
const compiled = compileTemplate({ source: descriptor.template.content, filename: 'operation.vue', id: 'receiving' })
|
||||
assert.deepEqual(compiled.errors, [])
|
||||
const quantityInput = descriptor.template.content.match(/<input[^>]*class="quantity-input"[^>]*>/)?.[0] || ''
|
||||
assert.match(quantityInput, /v-model\.number="packageCount"/)
|
||||
assert.match(quantityInput, /type="number" inputmode="numeric"/)
|
||||
assert.match(quantityInput, /:adjust-position="true" :cursor-spacing="120" :auto-blur="true"/)
|
||||
assert.match(quantityInput, /@focus="activeTarget = 'quantity'" @confirm="submitReceipt"/)
|
||||
assert.match(page, /class="quantity-unit">箱/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user