From a493a7faeb2dd2d687a4bdd527eabe977e236c3a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 14:53:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/loading-completion-state.test.mjs | 62 ++++++++++++++++++++++++ utils/loading-completion-state.js | 64 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/loading-completion-state.test.mjs create mode 100644 utils/loading-completion-state.js diff --git a/tests/loading-completion-state.test.mjs b/tests/loading-completion-state.test.mjs new file mode 100644 index 0000000..dbb8170 --- /dev/null +++ b/tests/loading-completion-state.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + COMPLETION_PHASE, + completionPresentation, + refreshCompletionAttempt, + resolveCompletionPhase, + shouldReconcileCompletion +} from '../utils/loading-completion-state.js' + +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 with the latest server version and command facts', () => { + 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, 7) + 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, '装柜已完成') +}) + diff --git a/utils/loading-completion-state.js b/utils/loading-completion-state.js new file mode 100644 index 0000000..0be9118 --- /dev/null +++ b/utils/loading-completion-state.js @@ -0,0 +1,64 @@ +export const COMPLETION_PHASE = Object.freeze({ + READY: 'READY', + SUBMITTING: 'SUBMITTING', + RECONCILING: 'RECONCILING', + PROCESSING: 'PROCESSING', + FAILED: 'FAILED', + COMPLETED: 'COMPLETED' +}) + +const PROCESSING_STATUSES = new Set(['COMPLETING', 'PROCESSING']) +const COMPLETED_STATUSES = new Set(['COMPLETED']) +const FAILED_STATUSES = new Set(['COMPLETION_FAILED']) + +function normalizedStatus(value) { + return String(value || '').trim().toUpperCase() +} + +/** + * Resolve already-normalized task/command facts into a UI phase. DTO field + * extraction deliberately stays outside this module until the backend schema + * is frozen. + */ +export function resolveCompletionPhase({ taskStatus, commandStatus, submitting = false, reconciling = false } = {}) { + if (reconciling) return COMPLETION_PHASE.RECONCILING + if (submitting) return COMPLETION_PHASE.SUBMITTING + const statuses = [taskStatus, commandStatus].map(normalizedStatus) + if (statuses.some((status) => COMPLETED_STATUSES.has(status))) return COMPLETION_PHASE.COMPLETED + if (statuses.some((status) => FAILED_STATUSES.has(status))) return COMPLETION_PHASE.FAILED + if (statuses.some((status) => PROCESSING_STATUSES.has(status))) return COMPLETION_PHASE.PROCESSING + return COMPLETION_PHASE.READY +} + +export function completionPresentation(phase, failureStage = '') { + if (phase === COMPLETION_PHASE.SUBMITTING) return { label: '正在提交', action: '', blocked: true } + if (phase === COMPLETION_PHASE.RECONCILING) return { label: '正在确认提交结果', action: '', blocked: true } + if (phase === COMPLETION_PHASE.PROCESSING) return { label: '完成处理中', action: '', blocked: true } + if (phase === COMPLETION_PHASE.COMPLETED) return { label: '装柜已完成', action: '', blocked: true } + if (phase === COMPLETION_PHASE.FAILED) { + return { label: failureStage ? `完成失败:${failureStage}` : '完成失败', action: '重试完成', blocked: true, retryable: true } + } + return { label: '待确认装柜', action: '确认装柜完成', blocked: false } +} + +export function shouldReconcileCompletion(error = {}) { + const code = normalizedStatus(error.code || error.errCode) + return Boolean(error.network || error.timeout || code === 'TIMEOUT' || code === 'REQUEST_TIMEOUT') +} + +/** + * A retry keeps the original operationId while taking the latest server + * version/command facts. It must never mint a new idempotency identity. + */ +export function refreshCompletionAttempt(previousAttempt = {}, latestFacts = {}) { + if (!previousAttempt.operationId) throw new Error('缺少原完成操作标识,不能安全重试') + const expectedTaskVersion = latestFacts.expectedTaskVersion ?? latestFacts.taskVersion ?? previousAttempt.expectedTaskVersion + if (expectedTaskVersion === null || expectedTaskVersion === undefined) throw new Error('缺少服务端最新任务版本,不能安全重试') + return { + ...previousAttempt, + ...latestFacts, + operationId: previousAttempt.operationId, + expectedTaskVersion: Number(expectedTaskVersion) + } +} +