Files
pda-uniapp/tests/loading-completion-state.test.mjs

182 lines
9.5 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import {
COMPLETION_PHASE,
completionPresentation,
observeLoadingCompletion,
refreshCompletionAttempt,
resolveCompletionPhase,
shouldReconcileCompletion
} from '../utils/loading-completion-state.js'
test('accepted completion queries immediately and returns as soon as the real task completes', async () => {
const reads = [], waits = [], applied = []
let clock = 0
const result = await observeLoadingCompletion({
initialTask: { taskStatus: 'COMPLETING' },
readTask: async () => {
reads.push(clock)
return { taskStatus: reads.length === 1 ? 'COMPLETING' : 'COMPLETED' }
},
isActive: () => true, onTask: task => applied.push(task.taskStatus), now: () => clock,
wait: async delay => { waits.push(delay); clock += delay }
})
assert.deepEqual(reads, [0, 250])
assert.deepEqual(waits, [250])
assert.deepEqual(applied, ['COMPLETING', 'COMPLETED'])
assert.equal(result.taskStatus, 'COMPLETED')
})
test('completion observation preserves failure, pending and unknown results without inventing success', async () => {
for (const status of ['COMPLETION_FAILED', 'LOADING']) {
const result = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
readTask: async () => ({ taskStatus: status }), isActive: () => true, onTask() {},
wait: async () => assert.fail('terminal or unknown facts must stop polling') })
assert.equal(result.taskStatus, status)
}
let clock = 0, reads = 0
const pending = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
readTask: async () => { reads++; return { taskStatus: 'COMPLETING' } },
isActive: () => true, onTask() {}, now: () => clock, maxWaitMs: 500,
wait: async delay => { clock += delay } })
assert.equal(pending.taskStatus, 'COMPLETING')
assert.equal(reads, 2)
})
test('hidden or switched tasks stop completion observation and discard in-flight responses', async () => {
let active = true, applied = 0
const result = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
readTask: async () => { active = false; return { taskStatus: 'COMPLETED' } },
isActive: () => active, onTask: () => { applied++ } })
assert.equal(result, null)
assert.equal(applied, 0)
await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
readTask: async () => assert.fail('hidden task must not query'), isActive: () => false, onTask() {} })
})
test('query failure is not retried as a completion mutation and completed acceptance needs no query', async () => {
await assert.rejects(observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
readTask: async () => { throw new Error('network unavailable') }, isActive: () => true, onTask() {} }), /network/)
const completed = { taskStatus: 'COMPLETED' }
assert.equal(await observeLoadingCompletion({ initialTask: completed,
readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), completed)
})
test('page only announces success without persisting a replay request after authoritative completion', async () => {
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
assert.ok(method)
for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) {
const messages = [], cleared = [], saved = []
const bindings = {
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null,
stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation',
saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt),
clearLoadingCompletionAttempt: identity => cleared.push(identity),
completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 },
normalizePickingTask: task => task,
observeLoadingCompletion: async options => {
assert.notEqual(status, 'NO_RESPONSE')
assert.equal(options.isActive(), true)
if (status === 'QUERY_ERROR') throw new Error('network error after acceptance')
const task = { taskStatus: status, version: 3 }
options.onTask(task)
return task
}
}
const behavior = new Function(...Object.keys(bindings), `return ({${method}})`)(...Object.values(bindings))
const context = {
requireEvidenceStage: () => true, evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' },
pickingVersion: 1, evidenceComplete: true, evidenceContextId: 1, remoteTaskId: 'task', remotePick: {},
draftIdentity: () => ({ loadingPlanId: 'plan', containerBookingId: 'cabinet' }),
isEvidenceContextActive: () => true, _pageVisible: true, debugSubmit() {}, persistSession() {},
showToast: (message, type) => messages.push({ message, type })
}
await behavior.completeLoadingTask.call(context)
assert.equal(context.submitting, false)
assert.equal(saved.length, 0)
assert.equal(cleared.length, 0)
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
if (status !== 'COMPLETED') assert.ok(context.remoteError)
}
})
test('reopened processing and completed tasks keep evidence and completion read-only', async () => {
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const methods = ['loadingCompletionState', 'evidenceReadOnly', 'requireEvidenceStage'].map(name => {
const method = page.match(new RegExp(`${name}\\(\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0]
assert.ok(method, name)
return method
})
const behavior = new Function('resolveCompletionPhase', 'completionPresentation', `return ({${methods.join('\n')}})`)(resolveCompletionPhase, completionPresentation)
for (const workflowStatus of ['PROCESSING', 'COMPLETED']) {
const messages = []
const context = { remotePick: { workflowStatus, completionCommand: { status: workflowStatus } }, submitting: false, pickingCompleted: true, loadingStageReady: true, showToast(message) { messages.push(message) } }
context.loadingCompletionState = behavior.loadingCompletionState.call(context)
context.evidenceReadOnly = behavior.evidenceReadOnly.call(context)
assert.equal(context.evidenceReadOnly, true)
assert.equal(behavior.requireEvidenceStage.call(context), false)
assert.deepEqual(messages, [workflowStatus === 'COMPLETED' ? '装柜已完成' : '完成处理中'])
}
const retry = { remotePick: { workflowStatus: 'COMPLETION_FAILED' }, submitting: false }
retry.loadingCompletionState = behavior.loadingCompletionState.call(retry)
assert.equal(behavior.evidenceReadOnly.call(retry), false)
assert.match(page, /v-if="evidenceReadOnly"[^>]*role="status"/)
assert.match(page, /v-else class="loading-complete-btn"/)
assert.match(page, /class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly \|\| evidenceUploading"/)
})
test('accepted completion is processing and blocks every repeated mutation', () => {
for (const facts of [
{ taskStatus: 'COMPLETING' },
{ commandStatus: 'PROCESSING' },
{ taskStatus: 'COMPLETING', commandStatus: 'PROCESSING' }
]) {
const phase = resolveCompletionPhase(facts)
assert.equal(phase, COMPLETION_PHASE.PROCESSING)
assert.deepEqual(completionPresentation(phase), { label: '完成处理中', action: '', blocked: true })
}
})
test('submission and uncertain-result reconciliation block duplicate completion immediately', () => {
assert.equal(resolveCompletionPhase({ submitting: true }), COMPLETION_PHASE.SUBMITTING)
assert.equal(resolveCompletionPhase({ reconciling: true }), COMPLETION_PHASE.RECONCILING)
assert.equal(completionPresentation(COMPLETION_PHASE.SUBMITTING).blocked, true)
assert.equal(completionPresentation(COMPLETION_PHASE.RECONCILING).blocked, true)
})
test('completion failure exposes a readable stage and retry entry', () => {
const phase = resolveCompletionPhase({ taskStatus: 'COMPLETION_FAILED' })
assert.equal(phase, COMPLETION_PHASE.FAILED)
assert.deepEqual(completionPresentation(phase, '费用结算'), {
label: '完成失败:费用结算', action: '重试完成', blocked: true, retryable: true
})
})
test('retry reuses the original operation id and request-hashed task version', () => {
const attempt = refreshCompletionAttempt(
{ operationId: 'complete-task-8-v3', expectedTaskVersion: 3, commandId: 'cmd-old' },
{ taskVersion: 7, commandId: 'cmd-latest', commandStatus: 'COMPLETION_FAILED' }
)
assert.equal(attempt.operationId, 'complete-task-8-v3')
assert.equal(attempt.expectedTaskVersion, 3)
assert.equal(attempt.commandId, 'cmd-latest')
assert.throws(() => refreshCompletionAttempt({}, { taskVersion: 7 }), /缺少原完成操作标识/)
assert.throws(() => refreshCompletionAttempt({ operationId: 'stable-id' }), /缺少服务端最新任务版本/)
})
test('network and timeout failures reconcile before any retry while business conflicts stay intact', () => {
assert.equal(shouldReconcileCompletion({ network: true }), true)
assert.equal(shouldReconcileCompletion({ timeout: true }), true)
assert.equal(shouldReconcileCompletion({ code: 'REQUEST_TIMEOUT' }), true)
assert.equal(shouldReconcileCompletion({ code: 'A0409', apiErrorMeta: { requestId: 'req-1' } }), false)
})
test('completed facts win over stale processing facts', () => {
const phase = resolveCompletionPhase({ taskStatus: 'COMPLETED', commandStatus: 'PROCESSING' })
assert.equal(phase, COMPLETION_PHASE.COMPLETED)
assert.equal(completionPresentation(phase).label, '装柜已完成')
})