Files
pda-uniapp/tests/picking-request-lifecycle.test.mjs
T

239 lines
15 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import vm from 'node:vm'
import { readFile } from 'node:fs/promises'
import { navigateBackOr } from '../utils/navigation.js'
import { COMPLETION_PHASE, observeLoadingCompletion, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js'
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
const stateSource = await readFile(new URL('../utils/picking-state.js', import.meta.url), 'utf8')
const pickingState = await import(`data:text/javascript;base64,${Buffer.from(stateSource).toString('base64')}`)
const noop = () => {}
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
function mount(overrides = {}, state = {}) {
const globals = { console, setTimeout, clearTimeout }
for (const match of script.matchAll(/import\s+(.*?)\s+from\s+['"][^'"]+['"]/g)) {
for (const name of match[1].replace(/[{}]/g, '').split(',').map(value => value.trim()).filter(Boolean)) globals[name] = noop
}
Object.assign(globals, { session: { token: 'offline', currentUser: { id: 'operator' } }, requireSession: () => true,
ITEM_STATUS: { PICKED: 'PICKED', PARTIAL: 'PARTIAL', SKIPPED: 'SKIPPED' },
isSealedPalletItem: () => false, isRemoteReady: () => true, stableOperationId: () => 'stable-operation',
stableTaskOperationIdentity: () => ({}), normalizePickingTask: value => value,
createItemStates: () => ({}), summarizeInventoryResources: () => ({}),
submitStates: () => ({ states: {}, autoSkippedIds: [] }), getPickSummary: () => ({}), ...overrides })
vm.createContext(globals)
vm.runInContext(script.replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page ='), globals)
const page = globals.page
const ctx = { taskRequestId: 0, _pageVisible: true, evidenceContextId: 0, remoteTaskId: 'task',
currentBox: { taskId: 'task', warehouseId: 'warehouse', inboundId: 'inbound', loadingPlanId: 'plan', containerBookingId: 'container',
items: [{ id: 'item', taskItemId: 'item', itemStatus: 'PENDING', qty: 2, version: 0 }] },
evidenceFiles: {}, evidenceIdentity: null, sealNo: '', pickingVersion: 1,
summary: { pending: 1 }, itemStates: {}, partialQuantities: {}, completedBoxes: {},
selectedIncompleteReason: '现场未找到', submitting: false, loadingStageReady: false, pickingCompleted: false }
for (const [name, method] of Object.entries(page.methods)) ctx[name] = method.bind(ctx)
Object.assign(ctx, { rememberEvidenceDraft: noop, draftIdentity: () => ({}), resetEvidenceState: noop,
debugSubmit: noop, debugEvidence: noop, showToast: noop, persistSession: noop, clearTimers: noop,
stopHardwareScanner: noop, startHardwareScanner: noop, captureEvidenceIdentity: () => ({}),
restoreEvidenceDraft: noop, isSameEvidenceIdentity: () => false, restoreRemoteDraft: noop,
itemState: () => 'PENDING', saveCurrentBoxDraft: noop, openOverlay: noop,
isTaskConflict: () => false, remoteFailureMessage: () => 'failed', ...state })
return { page, ctx }
}
test('real scan handlers display remaining 2→1→1→0, preserve pending semantics and avoid duplicate POST', async () => {
let posts = 0
const messages = []
const { page, ctx } = mount({ ...pickingState, pickContainerItem: async () => {
posts++
return { version: posts + 1, items: [{ id: 'item', plannedPackageCount: 2, pickedPackageCount: posts,
itemStatus: posts === 2 ? 'PICKED' : 'PICKING' }] }
} }, { scannedBoxCodes: {}, requirePickingOperation: () => true, recordScanResult: (...args) => messages.push(args) })
ctx.currentBox.items[0].boxCodes = ['BOX-1', 'BOX-2']
ctx.currentBox.items[0].plannedPackageCount = 2
ctx.currentBox.items[0].pickedPackageCount = 0
const remaining = () => page.computed.remainingCartons.call(ctx)
assert.equal(remaining(), 2)
await ctx.processScannedValue('BOX-1')
assert.equal(remaining(), 1)
assert.equal(page.computed.summary.call(ctx).pending, 0)
await ctx.processScannedValue('BOX-1')
assert.equal(remaining(), 1); assert.equal(posts, 1)
assert.equal(messages.at(-1)[3], '该条码已部分拣货,无需重复处理')
await ctx.processScannedValue('BOX-2')
assert.equal(remaining(), 0); assert.equal(posts, 2)
assert.match(source, /submit-pending-number">\{\{ remainingCartons \}\}/)
})
test('physical remaining distinguishes skipped and partial from business pending and trusts server counts', () => {
const items = [{ id: 'partial', qty: 4, pickedPackageCount: 1 }, { id: 'skip', qty: 3, pickedPackageCount: 0 }]
const states = { partial: pickingState.ITEM_STATUS.PARTIAL, skip: pickingState.ITEM_STATUS.SKIPPED }
const quantities = { partial: 1 }
const before = JSON.stringify({ items, states, quantities })
const summary = pickingState.getPickSummary(items, states, quantities)
assert.equal(summary.pending, 0); assert.equal(summary.partial, 1); assert.equal(summary.skipped, 3)
assert.equal(pickingState.remainingPhysicalCartons(items, states, quantities), 6)
assert.equal(pickingState.getPickSummary(items, states, quantities).canSubmit, summary.canSubmit)
assert.equal(JSON.stringify({ items, states, quantities }), before)
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'partial', qty: 4, pickedPackageCount: 1 }],
{ partial: pickingState.ITEM_STATUS.PICKED }, { partial: 4 }), 3)
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'partial', qty: 4 }], states, quantities), 3)
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'skip', qty: 3 }], states, quantities), 3)
})
test('double confirmation and direct reentry send exactly one snapshot and one batch', async () => {
const snapshot = deferred(), batch = deferred(); let snapshots = 0, batches = 0
const { ctx } = mount({ submitContainerPickingBatch: async () => { batches++; return batch.promise } }, {
refreshInventorySnapshot: async () => { snapshots++; return snapshot.promise },
syncRemoteTask: async function () { this.pickingCompleted = true },
})
const first = ctx.confirmIncompleteSubmit()
ctx.confirmIncompleteSubmit(); await ctx.performSubmit('现场未找到')
assert.equal(ctx.submitting, true); assert.equal(snapshots, 1); assert.equal(batches, 0)
snapshot.resolve({ snapshotToken: 'snapshot' }); await new Promise(setImmediate)
ctx.confirmIncompleteSubmit(); assert.equal(batches, 1)
batch.resolve(null); await first
assert.equal(snapshots, 1); assert.equal(batches, 1); assert.equal(ctx.submitting, false)
assert.match(source, /:disabled="submitting \|\| !selectedIncompleteReason"/)
})
test('failed request releases submit guard and retry keeps operation identity', async () => {
const keys = []; let snapshots = 0
const { ctx } = mount({ submitContainerPickingBatch: async (_task, _actions, options) => { keys.push(options.operationId); throw new Error('network') } }, {
refreshInventorySnapshot: async () => { snapshots++; return { snapshotToken: 'snapshot' } },
})
await ctx.confirmIncompleteSubmit(); assert.equal(ctx.submitting, false)
await ctx.confirmIncompleteSubmit()
assert.equal(snapshots, 2); assert.deepEqual(keys, ['stable-operation', 'stable-operation'])
})
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
test('refreshing a completed task returns to the list before claiming or loading evidence', async () => {
const destinations = []; let claims = 0, evidence = 0
const { ctx } = mount({ getContainerPickingTask: async () => ({ ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }),
navigateBackOr: url => destinations.push(url),
claimContainerPickingTask: async () => { claims++ } }, { loadLoadingEvidence: async () => { evidence++ } })
await ctx.syncRemoteTask()
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
assert.equal(ctx.remotePick.taskStatus, 'COMPLETED')
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
})
for (const entry of ['completion', 'refresh']) {
for (const control of ['dropdown', 'overlay']) {
test(`${entry} returns through onBackPress even when the ${control} opens before completion`, async t => {
const previousUni = globalThis.uni, previousPages = globalThis.getCurrentPages
t.after(() => { globalThis.uni = previousUni; globalThis.getCurrentPages = previousPages })
const response = deferred(), destinations = []; let popupClosed = 0
const completed = { ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
const { page, ctx } = mount({ navigateBackOr, COMPLETION_PHASE, observeLoadingCompletion,
resolveCompletionPhase, shouldReconcileCompletion,
completeContainerPickingTask: () => response.promise, getContainerPickingTask: () => response.promise },
{ evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceComplete: true,
currentBoxIndex: 0, activeOverlay: '', boxDropdownOpen: false,
requireEvidenceStage: () => true, isEvidenceContextActive: () => true,
$nextTick: callback => callback(), $refs: { boxListPopup: { open() {}, close() { popupClosed++ } } } })
ctx.openOverlay = page.methods.openOverlay.bind(ctx)
globalThis.getCurrentPages = () => [{ route: 'pages/tasks/tasks' }, { route: 'pages/picking/picking' }]
globalThis.uni = {
// App 将返回钩子的拦截视为成功;仅调用导航函数不能证明页面已返回。
navigateBack() { if (!page.onBackPress.call(ctx)) destinations.push('/pages/tasks/tasks') },
reLaunch: ({ url }) => destinations.push(url)
}
const pending = entry === 'completion' ? ctx.completeLoadingTask() : ctx.syncRemoteTask()
if (control === 'dropdown') ctx.toggleBoxDropdown()
else ctx.openOverlay('boxList')
response.resolve(completed)
await pending
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
assert.equal(ctx.boxDropdownOpen, false); assert.equal(ctx.activeOverlay, '')
assert.equal(popupClosed, control === 'overlay' ? 1 : 0)
})
}
}
test('completed tasks update the real history state while clearing only their own persisted draft', async t => {
const previousUni = globalThis.uni
t.after(() => { globalThis.uni = previousUni })
const moduleUrl = value => `data:text/javascript;base64,${Buffer.from(value).toString('base64')}`
let storeSource = await readFile(new URL('../stores/picking.js', import.meta.url), 'utf8')
for (const path of ['data/demo-data.js', 'utils/picking-state.js', 'utils/task-identity.js']) {
storeSource = storeSource.replace(`@/${path}`, moduleUrl(await readFile(new URL(`../${path}`, import.meta.url), 'utf8')))
}
const store = await import(moduleUrl(storeSource)), storage = new Map(), writes = []
globalThis.uni = { getStorageSync: key => storage.get(key),
setStorageSync: (key, value) => { writes.push(key); storage.set(key, value) }, removeStorageSync: key => storage.delete(key) }
const identity = { operatorId: 'operator', taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'container' }
const { page, ctx } = mount({ clearPickingDraft: store.clearPickingDraft, savePickingSession: store.savePickingSession },
{ remotePick: { taskStatus: 'LOADING' }, draftIdentity: () => identity, currentBoxIndex: 0,
boxes: [{ taskId: 'task', cabinetNo: 'D2126', items: [] }], boxDrafts: {}, scanHistory: [] })
ctx.persistSession = page.methods.persistSession.bind(ctx)
ctx.persistSession()
const draftKey = store.getPickingDraftKey(identity)
assert.equal(storage.has(draftKey), true); assert.equal(store.getPickingOverview().completedCount, 0)
storage.set('another-task-draft', { keep: true })
writes.length = 0
ctx.remotePick = { taskStatus: 'COMPLETED' }
ctx.persistSession()
page.onUnload.call(ctx)
assert.equal(store.getPickingOverview().completedCount, 1)
assert.equal(store.getPickingOverview().completedBoxes[0], true)
assert.equal(store.getPickingOverview().boxes[0].taskId, 'task')
assert.equal(storage.has(draftKey), false); assert.equal(storage.has('another-task-draft'), true)
assert.deepEqual(writes, [])
})
for (const hook of ['onHide', 'onUnload']) {
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
const detail = deferred(); let claims = 0, evidence = 0
const { page, ctx } = mount({ getContainerPickingTask: () => detail.promise,
claimContainerPickingTask: async () => { claims++; return {} } }, { loadLoadingEvidence: async () => { evidence++ } })
const pending = ctx.syncRemoteTask(); page[hook].call(ctx); detail.resolve(task()); await pending
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
})
}
test('unload during already-sent claim prevents its response from loading evidence', async () => {
const claim = deferred(); let claims = 0, evidence = 0
const { page, ctx } = mount({ getContainerPickingTask: async () => task(),
claimContainerPickingTask: () => { claims++; return claim.promise } }, { loadLoadingEvidence: async () => { evidence++ } })
const pending = ctx.syncRemoteTask(); await new Promise(setImmediate)
assert.equal(claims, 1, ctx.remoteError)
page.onUnload.call(ctx); claim.resolve({ workflowStatus: 'IN_PROGRESS', version: 2 }); await pending
assert.equal(evidence, 0); assert.equal(ctx.remotePick, undefined)
})
test('unload during onShow list fetch prevents starting task sync afterward', async () => {
const list = deferred(); let syncs = 0
const { page, ctx } = mount({}, { _pageVisible: false, refreshAvailableBoxes: () => list.promise,
syncRemoteTask: async () => { syncs++ } })
const pending = page.onShow.call(ctx); page.onUnload.call(ctx); list.resolve(); await pending
assert.equal(syncs, 0)
})
test('old hidden onShow cannot clear a newer visible refresh or start another sync', async () => {
const oldList = deferred(), newList = deferred(); let lists = 0, syncs = 0
const { page, ctx } = mount({}, { _pageVisible: false,
refreshAvailableBoxes: () => (++lists === 1 ? oldList.promise : newList.promise),
syncRemoteTask: async () => { syncs++ } })
const oldShow = page.onShow.call(ctx)
page.onHide.call(ctx)
const newShow = page.onShow.call(ctx), currentPromise = ctx._showRefreshPromise
oldList.resolve(); await oldShow
assert.equal(syncs, 0); assert.equal(ctx._showRefreshPromise, currentPromise)
newList.resolve(); await newShow
assert.equal(syncs, 1); assert.equal(ctx._showRefreshPromise, null)
})
for (const mode of ['camera', 'preview']) {
test(`${mode} hide/return keeps existing exception and avoids refresh`, async () => {
let refreshes = 0
const { page, ctx } = mount({}, { cameraPicking: mode === 'camera', evidencePreviewOpening: mode === 'preview',
refreshAvailableBoxes: async () => { refreshes++ }, syncRemoteTask: async () => { refreshes++ } })
page.onHide.call(ctx); assert.equal(ctx.taskRequestId, 0)
await page.onShow.call(ctx); assert.equal(refreshes, 0); assert.equal(ctx._pageVisible, true)
})
}