import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js' 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] function completion(overrides = {}, savedAttempt = null) { let attempt = savedAttempt const calls = [], cleared = [], messages = [] const bindings = { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion, isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`, restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value }, clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null }, completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } }, normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask, sanitizeUserMessage: value => value, ...overrides } const behavior = new Function(...Object.keys(bindings), `return ({${method}})`)(...Object.values(bindings)) const state = { evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, remoteTaskId: 'task', remotePick: {}, pickingVersion: 1, evidenceComplete: true, submitting: false, _pageVisible: true, requireEvidenceStage() { return !this.submitting }, draftIdentity: () => ({ taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'cabinet' }), isEvidenceContextActive(id) { return id === this.evidenceContextId }, isTaskConflict: error => error.code === 'A0409', async refreshAfterTaskConflict() { this.pickingVersion = 2 }, debugSubmit() {}, persistSession() {}, remoteFailureMessage: error => error.message, showToast: message => messages.push(message) } state.complete = behavior.completeLoadingTask.bind(state) return { state, calls, cleared, messages, attempt: () => attempt } } test('completion in flight blocks a second click and releases busy after the task context changes', async () => { let resolve const { state, calls } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } }) const first = state.complete(); await state.complete(); assert.equal(calls.length, 1) state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined) }) test('an old completion response cannot release a newer picking submission', async () => { let resolve const { state } = completion({ completeContainerPickingTask: () => new Promise(done => { resolve = done }) }) const first = state.complete() state.evidenceContextId++; state._submissionOwner = {}; state.submitting = true resolve({ taskStatus: 'COMPLETED' }); await first assert.equal(state.submitting, true) }) test('timeout unlocks completion and the next click uses the current version', async () => { const commands = [] const { state, attempt } = completion({ completeContainerPickingTask: async (_task, payload) => { commands.push(payload); if (commands.length === 1) throw { network: true }; return { taskStatus: 'COMPLETED' } } }) await state.complete(); assert.equal(state.submitting, false); assert.equal(attempt(), null) state.pickingVersion = 7; await state.complete() assert.equal(commands[1].expectedTaskVersion, 7); assert.notEqual(commands[0].operationId, commands[1].operationId) }) test('first definite conflict releases its stale completion version so the next operation can succeed', async () => { const commands = [] const { state, cleared } = completion({ completeContainerPickingTask: async (_task, payload) => { commands.push(payload); if (commands.length === 1) throw { code: 'A0409', message: '任务已更新' }; return { taskStatus: 'COMPLETED' } } }) await state.complete(); assert.equal(state.submitting, false); assert.equal(cleared.length, 0) await state.complete(); assert.equal(commands[1].expectedTaskVersion, 2); assert.notEqual(commands[0].operationId, commands[1].operationId) }) test('old local completion snapshots are ignored and cannot freeze the current task', async () => { const old = { operationId: 'old', expectedTaskVersion: 1 } const { state, calls } = completion({}, old) state.pickingVersion = 8; await state.complete() assert.equal(calls[0].expectedTaskVersion, 8); assert.notEqual(calls[0].operationId, old.operationId) assert.equal(state.submitting, false) }) test('a reopened processing task has an explicit refresh action without a mutation retry', () => { assert.match(page, /v-if="evidenceReadOnly && loadingCompletionState.phase !== 'COMPLETED'"[^>]*@tap="refreshTask">刷新完成状态/) }) test('photo failure releases the page and another click uploads the current photo with a fresh key', async () => { const uploadMethod = page.slice(page.indexOf('async queueEvidenceUpload('), page.indexOf('async saveSealNo(')) const requests = [] const uploadEvidenceFile = async (...args) => { requests.push(args); if (requests.length === 1) throw { network: true, message: 'timeout' }; return { saved: true } } const behavior = new Function('isRemoteReady', 'uploadEvidenceFile', 'return ({' + uploadMethod + '})')(() => true, uploadEvidenceFile) const state = { evidenceUploading: false, evidenceReadOnly: false, evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, evidenceQueueItems: [], remoteError: '', validEvidenceEntries: () => [], isEvidenceContextActive: () => true, remoteFailureMessage: error => error.message, applyEvidenceResponse() {}, showToast() {} } await behavior.queueEvidenceUpload.call(state, 'SEAL', 'old.jpg', 'OLD') assert.equal(state.evidenceUploading, false); assert.equal(state.remoteError, 'timeout') await behavior.queueEvidenceUpload.call(state, 'SEAL', 'new.jpg', 'NEW') assert.equal(state.evidenceUploading, false); assert.equal(requests[1][3], 'new.jpg'); assert.equal(requests[1][2].sealNo, 'NEW') assert.notEqual(requests[1][2].operationId, requests[0][2].operationId) }) test('failed ordinary photo is removed from the visible and restored draft without deleting saved evidence', async () => { const uploadMethod = page.slice(page.indexOf('async queueEvidenceUpload('), page.indexOf('async saveSealNo(')) const draftMethods = page.slice(page.indexOf('evidenceDraftKey(identity)'), page.indexOf('requireEvidenceCapture() {')) const behavior = new Function('isRemoteReady', 'uploadEvidenceFile', 'return ({' + draftMethods + uploadMethod + '})')( () => true, async () => { throw new Error('upload failed') } ) const saved = { id: 'saved-photo', fileId: 'saved-file', localPath: 'saved.jpg' } const failed = { localPath: 'failed.jpg', uploadStatus: 'PENDING' } const seal = { localPath: 'seal.jpg', uploadStatus: 'PENDING' } const state = { ...behavior, evidenceUploading: false, evidenceReadOnly: false, evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, evidenceQueueItems: [], evidenceDrafts: {}, sealNo: 'SEAL-A', evidenceFiles: { PHOTO_STEP_1: [saved, failed], SEAL: [seal] }, validEvidenceEntries: () => [saved], isEvidenceContextActive: () => true, remoteFailureMessage: error => error.message, showToast() {} } state.rememberEvidenceDraft() await state.queueEvidenceUpload('PHOTO_STEP_1', 'failed.jpg') assert.deepEqual(state.evidenceFiles.PHOTO_STEP_1, [saved]) assert.equal(state.evidenceUploading, false) assert.equal(state.remoteError, 'upload failed') state.restoreEvidenceDraft() assert.equal(state.evidenceFiles.PHOTO_STEP_1, undefined) assert.deepEqual(state.evidenceFiles.SEAL, [seal]) await state.queueEvidenceUpload('SEAL', 'seal.jpg', 'SEAL-A') assert.deepEqual(state.evidenceFiles.SEAL, [seal]) assert.equal(state.evidenceUploading, false) })