fix(pda): bind loading evidence to cabinet tasks

This commit is contained in:
2026-08-31 18:08:43 +08:00
parent e686e13772
commit 63898d9a32
5 changed files with 174 additions and 259 deletions
+32 -23
View File
@@ -6,23 +6,20 @@ const source = await readFile(new URL('../api/pda-loading.js', import.meta.url),
const http = await readFile(new URL('../api/http.js', import.meta.url), 'utf8')
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
test('PDA loading adapter keeps C04 read and write paths', () => {
test('PDA loading adapter uses only task-scoped cabinet evidence paths', () => {
for (const path of [
'/first-leg/pda-loading/tasks/pending',
'/loading/picking?tenantId=',
'/loading/picking/start?tenantId=',
'/loading/picking/complete?tenantId=',
'/loading/evidence/pda?tenantId=',
'/loading/complete?tenantId='
'/first-leg/pda-loading/tasks/pending'
]) assert.match(source, new RegExp(path.replace(/[?]/g, '\\?')))
assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence/)
assert.match(source, /'Idempotency-Key'/)
assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/)
for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label))
assert.match(source, /apiUpload\(/)
assert.match(http, /uni\.uploadFile/)
assert.match(source, /\/files\/loading-evidence\//)
assert.match(source, /loading\/evidence\/pda\?tenantId=/)
assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void\/pda\?tenantId=/)
assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\?\$\{query\.join\('&'\)\}/)
assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void/)
assert.doesNotMatch(source, /inbounds\/\$\{encodeURIComponent\(inboundId\)\}\/loading\/evidence/)
assert.doesNotMatch(source, /loading\/evidence\/pda\?tenantId=/)
assert.match(source, /export function completeContainerPickingTask\(taskId, payload = \{\}\)/)
assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/complete/)
assert.match(source, /expectedTaskVersion/)
@@ -31,7 +28,7 @@ test('PDA loading adapter keeps C04 read and write paths', () => {
test('PDA adapter does not treat non-200 response bodies or network failures as success', () => {
assert.match(http, /if \(code !== '200'\)/)
assert.match(http, /next\.network = true/)
assert.match(source, /throw new Error\('缺少客户隔离标识 tenantId/)
assert.match(source, /throw new Error\('缺少装柜任务标识,无法上传装柜照片/)
})
test('C02 identifiers and separate picking/inbound versions are normalized explicitly', () => {
@@ -39,14 +36,14 @@ test('C02 identifiers and separate picking/inbound versions are normalized expli
assert.match(source, /inboundVersion: row\.inboundVersion/)
assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/)
assert.match(source, /pickingVersion: task\.pickingVersion \?\? task\.version/)
assert.match(page, /expectedVersion: Number\(this\.pickingVersion\)/)
assert.match(page, /version: Number\(this\.pickingVersion\)/)
assert.match(page, /ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates/)
assert.match(page, /taskItemId: item\.id \?\? item\.itemId/)
assert.match(page, /submit\.batch/)
assert.match(page, /item\.itemStatus === 'PICKED'/)
assert.match(page, /item\.itemStatus === 'PARTIAL'/)
assert.match(page, /!pickingCompleted && !summary\.canSubmit/)
assert.match(page, /this\.loadingVersion = this\.remotePick\.inboundVersion \?\? this\.remotePick\.loadingVersion/)
assert.doesNotMatch(page, /loadingVersion/)
assert.match(page, /状态尚未同步,请刷新重试/)
})
@@ -58,14 +55,14 @@ test('PDA normalizes the frozen active and merged container identity fields', ()
})
test('picking page exposes five evidence slots, retry and remote completion states', () => {
for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成', 'loadingVersion']) {
for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成']) {
assert.match(`${source}\n${page}`, new RegExp(text))
}
assert.match(page, /createEvidenceUploadQueue/)
assert.match(page, /retryEvidence/)
assert.match(page, /uploadEvidenceFile/)
assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
assert.match(page, /completePicking\(/)
assert.doesNotMatch(page, /completePicking\(/)
assert.match(page, /sourceType: \['camera'\]/)
assert.doesNotMatch(page, /sourceType: \['camera', 'album'\]/)
assert.match(page, /evidenceEntries\(slot\.type\)/)
@@ -73,15 +70,16 @@ test('picking page exposes five evidence slots, retry and remote completion stat
assert.match(page, /evidenceComplete/)
})
test('PDA evidence binding unwraps the nested loading action response before rendering saved files', () => {
assert.match(page, /evidenceItems\(result\)[\s\S]*?Array\.isArray\(result\?\.evidences\?\.evidences\)[\s\S]*?result\.evidences\.evidences/)
assert.match(page, /this\.evidenceFiles = this\.evidenceItems\(result\)[\s\S]*?\.reduce/)
assert.match(page, /cacheEvidencePreview\(current\.fileId, current\.localPath/)
test('PDA evidence binding normalizes string ids from the task-scoped DTO', () => {
assert.match(page, /evidenceItems\(result\)[\s\S]*?id: String\(item\.evidenceId \?\? item\.id/)
assert.match(page, /fileId: String\(item\.fileId/)
assert.match(page, /applyEvidenceResponse\(result, type, localPath, identity\)[\s\S]*?this\.evidenceFiles = remoteItems[\s\S]*?\.reduce/)
assert.match(page, /cacheEvidencePreview\(saved\.fileId, localPath/)
})
test('PDA downloads authenticated evidence previews returned by the backend', () => {
assert.match(source, /export function getEvidencePreviewUrl\(inboundId, evidenceId, tenantId\)/)
assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\/pda\?tenantId=/)
test('PDA downloads authenticated task-scoped evidence previews', () => {
assert.match(source, /export function getEvidencePreviewUrl\(taskId, evidenceId, warehouseId\)/)
assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\?warehouseId=/)
assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/)
assert.match(source, /apiDownload\(\{ url \}\)/)
assert.match(http, /uni\.downloadFile\(/)
@@ -93,6 +91,17 @@ test('PDA downloads authenticated evidence previews returned by the backend', ()
assert.match(source, /const url = resolveEvidencePreviewUrl\(previewUrl, session\.apiBase/)
})
test('cabinet evidence never selects an inbound owner or builds a container route in the PDA', () => {
const evidenceAdapter = source.match(/export function getEvidence\(taskId, warehouseId\)[\s\S]*?export function completeContainerPickingTask/)?.[0] || ''
for (const field of ['inboundId', 'tenantId', 'containerBookingId']) assert.doesNotMatch(evidenceAdapter, new RegExp(field))
assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/)
assert.match(page, /uploadEvidenceFile\(item\.taskId, item\.warehouseId, item, item\.localPath/)
assert.match(page, /queue\.add\(\{ taskId: identity\.taskId, warehouseId: identity\.warehouseId, evidenceType: type, expectedVersion/)
assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/)
assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/)
assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
})
test('PDA cabinet picking adapter claims online and submits one atomic batch', () => {
assert.match(source, /\/first-leg\/pda-loading\/tasks\/pending/)
assert.doesNotMatch(source, /query\.push\(`cabinetNo=/)
@@ -125,7 +134,7 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', (
assert.match(page, /库存快照未同步,请刷新任务后重试/)
assert.match(page, /拣货完成未提交,请查看错误提示/)
assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\)[\s\S]*claimContainerPickingTask/)
assert.match(page, /this\.loadingVersion = this\.remotePick\.inboundVersion \?\? this\.remotePick\.loadingVersion/)
assert.doesNotMatch(page, /loadingVersion/)
assert.match(page, /taskRequestId/)
assert.match(page, /resetEvidenceState/)
assert.match(page, /availablePickQuantity/)
+24 -35
View File
@@ -32,10 +32,10 @@ test('picking completion leads to current cabinet evidence instead of an all-com
assert.doesNotMatch(page, /查看全部完成|allCompletePopup|nextBox\(\)/)
})
test('loading evidence is requested as soon as the claimed task has a stable evidence identity', () => {
test('loading evidence is requested by task id as soon as the task is claimed', () => {
assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)[\s\S]*?await this\.loadLoadingEvidence\(\)/)
const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(loader, /if \(!this\.evidenceIdentity\?\.inboundId/)
assert.match(loader, /if \(!this\.evidenceIdentity\?\.taskId/)
assert.doesNotMatch(loader, /if \(!this\.loadingStageReady/)
assert.match(loader, /await getEvidence\(/)
})
@@ -50,8 +50,8 @@ test('operators receive actionable refresh guidance without internal version wor
assert.doesNotMatch(complete, /completeLoading\(inboundId/)
})
test('AWAITING_REVIEW without a server loading version blocks final completion but not evidence maintenance', () => {
assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.loadingVersion !== null[\s\S]*?Number\.isFinite/)
test('AWAITING_REVIEW uses the canonical task version for final completion', () => {
assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.evidenceIdentity\.taskId[\s\S]*?this\.evidenceIdentity\.warehouseId[\s\S]*?this\.pickingVersion !== null[\s\S]*?Number\.isFinite/)
assert.match(page, /拣货已完成,装柜状态尚未同步,请刷新重试/)
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?requireEvidenceStage/)
assert.match(page, /requireEvidenceMutation\(\)[\s\S]*?requireEvidenceCapture/)
@@ -119,35 +119,28 @@ test('final completion preserves backend gate errors and refreshes after an atom
const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(sanitizeUserMessage\(error\.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'\)\)/)
assert.doesNotMatch(complete, /拖车|trucking/i)
const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(save, /this\.isTaskConflict\(error\)[\s\S]*?refreshAfterTaskConflict/)
const flush = page.match(/async flushPendingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(flush, /!entry\.id && entry\.localPath/)
assert.match(flush, /if \(item\.fileId && item\.type !== 'SEAL'\) await this\.saveEvidence\(item\.type, null, item\.path\)/)
assert.match(flush, /else if \(item\.fileId\) continue/)
assert.match(flush, /else await this\.queueEvidenceUpload\(item\.type, item\.path\)/)
})
test('seal photo upload waits for an explicit seal-number save without waiting for picking completion', () => {
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(queue, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type, null, path\)/)
assert.match(retry, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type\)/)
assert.match(save, /type === 'SEAL' && !String\(this\.sealNo \|\| ''\)\.trim\(\)[\s\S]*?请输入封签号后点击保存封签/)
assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.saveEvidence\('SEAL'/)
const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(chooser, /slot\.type === 'SEAL'[\s\S]*?带锁码照已拍摄,请输入封签号并保存[\s\S]*?return/)
assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/)
assert.match(page, /hasUnboundUploadedEvidence\(slot\.type\)[\s\S]*?照片已上传,请输入封签号并保存/)
assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/)
})
test('evidence identity is bound to task, inbound, loading plan and container booking', () => {
assert.match(page, /captureEvidenceIdentity\(\)[\s\S]*?taskId:[\s\S]*?inboundId:[\s\S]*?loadingPlanId:[\s\S]*?containerBookingId:/)
assert.match(page, /isSameEvidenceIdentity\(left, right\)[\s\S]*?taskId[\s\S]*?inboundId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/)
assert.match(page, /this\.currentBox\.inboundId = task\.inboundId/)
test('cabinet evidence identity is task-scoped and never selects an inbound owner', () => {
const capture = page.match(/captureEvidenceIdentity\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
const same = page.match(/isSameEvidenceIdentity\(left, right\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(capture, /taskId: this\.remoteTaskId \|\| this\.currentBox\.taskId/)
assert.match(capture, /warehouseId: this\.currentBox\.warehouseId/)
assert.doesNotMatch(capture, /inboundId|loadingPlanId|containerBookingId/)
assert.match(same, /left\?\.taskId[\s\S]*?right\?\.taskId/)
assert.doesNotMatch(same, /inboundId|loadingPlanId|containerBookingId/)
assert.match(page, /String\(detailTaskId \|\| ''\) !== String\(requestedTaskId\)/)
assert.match(page, /!task\.inboundId \|\| !task\.loadingPlanId \|\| !task\.containerBookingId/)
assert.match(page, /evidenceItem[\s\S]*?String\(task\.inboundId\)/)
assert.match(page, /!task\.loadingPlanId \|\| !task\.containerBookingId/)
assert.doesNotMatch(page, /const evidenceItem|String\(task\.inboundId\)/)
assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)/)
})
@@ -156,28 +149,26 @@ test('merged or non-active cabinet details are rejected before claim and clear s
assert.match(sync, /if \(this\.isMergedContainerRecord\(task\)\)[\s\S]*?this\.clearMergedTask\(task, previousIdentity\)[\s\S]*?return[\s\S]*?task\.recordStatus !== 'ACTIVE'[\s\S]*?claimContainerPickingTask/)
const clear = page.match(/clearMergedTask\(task, previousIdentity\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(clear, /taskId: previousIdentity\?\.taskId \?\? task\?\.taskId/)
assert.match(clear, /inboundId: previousIdentity\?\.inboundId \?\? task\?\.inboundId/)
assert.match(clear, /loadingPlanId: previousIdentity\?\.loadingPlanId \?\? task\?\.loadingPlanId/)
assert.match(clear, /containerBookingId: previousIdentity\?\.containerBookingId \?\? task\?\.containerBookingId/)
assert.match(clear, /clearPickingDraft\(staleIdentity\)/)
assert.match(clear, /this\.evidenceDrafts = Object\.fromEntries/)
assert.match(clear, /this\.evidencePreviewCache = Object\.fromEntries/)
for (const reset of ['remoteLoading = false', 'submitting = false', 'remoteTaskId = null', 'loadingVersion = null', 'scanHistory = []', 'currentBox.containerBookingId = null', 'currentBox.currentContainerNo = null', 'resetEvidenceState()']) assert.match(clear, new RegExp(reset.replace(/[.()[\]]/g, '\\$&')))
for (const reset of ['remoteLoading = false', 'submitting = false', 'remoteTaskId = null', 'scanHistory = []', 'currentBox.containerBookingId = null', 'currentBox.currentContainerNo = null', 'resetEvidenceState()']) assert.match(clear, new RegExp(reset.replace(/[.()[\]]/g, '\\$&')))
})
test('photos upload, bind, reload and void before picking completion', () => {
assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/)
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(queue, /await queue\.flush\(\)[\s\S]*?await this\.saveEvidence\(type, null, path\)/)
assert.match(queue, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, path, identity\)/)
assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/)
assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/)
const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(retry, /if \(!this\.requireEvidenceCapture\(\)\) return/)
assert.match(retry, /await queue\.flush\(\)[\s\S]*?await this\.saveEvidence\(type\)/)
assert.match(retry, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, item\.localPath, identity\)/)
assert.doesNotMatch(retry, /if \(!this\.loadingStageReady\)/)
assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.inboundId[\s\S]*?getEvidence/)
assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.taskId[\s\S]*?!this\.evidenceIdentity\?\.warehouseId[\s\S]*?getEvidence\(identity\.taskId, identity\.warehouseId\)/)
assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
assert.match(page, /async saveEvidence\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
assert.match(page, /async voidEvidencePhoto\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceStage\(\)\) return/)
assert.doesNotMatch(page, /照片已上传,拣货完成后自动保存/)
@@ -200,7 +191,7 @@ test('evidence photos render thumbnails and support full-screen preview', () =>
assert.match(page, /previewEvidencePhoto\(photo, type\)[\s\S]*?uni\.previewImage\([\s\S]*?current[\s\S]*?urls/)
assert.match(page, /cacheEvidencePreview\(fileId, localPath/)
assert.match(page, /withCachedEvidencePreview\(photo[\s\S]*?evidencePreviewCache/)
assert.match(page, /item\.previewUrl \|\| getEvidencePreviewUrl\(identity\.inboundId, item\.id, this\.currentBox\.tenantId\)/)
assert.match(page, /item\.previewUrl \|\| getEvidencePreviewUrl\(identity\.taskId, item\.id, identity\.warehouseId\)/)
assert.match(page, /['"]list\.loaded['"]/)
assert.match(page, /loadEvidencePreviews\(contextId, identity\)[\s\S]*?downloadEvidencePreview\(item\.previewUrl\)/)
assert.match(page, /['"]preview\.downloaded['"]/)
@@ -211,7 +202,6 @@ test('switching cabinets invalidates old sync and evidence upload state', () =>
const selector = page.match(/selectBox\(index\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(selector, /this\.taskRequestId \+= 1/)
assert.match(selector, /this\.pickingVersion = null/)
assert.match(selector, /this\.loadingVersion = null/)
assert.match(selector, /this\.submitting = false/)
assert.match(selector, /this\.resetEvidenceState\(\)/)
assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/)
@@ -247,7 +237,6 @@ test('returning from an image preview does not refresh the picking task', () =>
test('evidence save, void and loading completion ignore stale cabinet responses', () => {
for (const [method, awaitedCall] of [
['saveEvidence', 'upsertEvidence'],
['voidEvidencePhoto', 'voidEvidence'],
['completeLoadingTask', 'completeContainerPickingTask']
]) {
@@ -270,7 +259,7 @@ test('completed server tasks restore evidence stage without being claimed again'
})
test('picking mutations stay closed until the canonical task has been claimed', () => {
assert.match(page, /pickingOperationReady\(\)[\s\S]*?workflowStatus === 'IN_PROGRESS'[\s\S]*?remoteTaskId[\s\S]*?inboundId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/)
assert.match(page, /pickingOperationReady\(\)[\s\S]*?workflowStatus === 'IN_PROGRESS'[\s\S]*?remoteTaskId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/)
assert.match(page, /requirePickingOperation\(\)[\s\S]*?任务尚未领取完成/)
for (const method of ['processScannedValue', 'pickItem', 'openSkip', 'openPartial', 'submitPicking']) {
const body = page.match(new RegExp(`${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || ''
+4 -3
View File
@@ -4,12 +4,13 @@ import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
test('saving a seal is single-flight and reuses a stable photo-bound operation', () => {
test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => {
assert.match(page, /:disabled="sealSaving \|\| !sealReadyToSave"/)
assert.match(page, /sealSaving \? '保存中…' : '保存封签'/)
assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/)
assert.match(page, /find\(\(item\) => item\.fileId && !item\.id\)/)
assert.match(page, /stableOperationId\('seal',[\s\S]*?fileId: String\(current\.fileId\)[\s\S]*?sealNo: String\(this\.sealNo\)\.trim\(\)/)
assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)
assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
assert.match(page, /stableOperationId\('evidence-upload',[\s\S]*?evidenceType: type,[\s\S]*?expectedVersion,[\s\S]*?localPath: path,[\s\S]*?sealNo: sealNo \|\| ''/)
assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/)
assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/)
})