2026-10-21 17:27:13 +08:00
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
2026-10-26 17:12:38 +08:00
import { loadingPhotoLabels , loadingPhotoLimit , loadingPhotoSlots , normalizeLoadingDocuments , getLoadingDocuments , bindLoadingPhoto , uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
2026-10-21 17:27:13 +08:00
import { session } from '../src/stores/session.js'
const source = ( await readFile ( new URL ( '../src/components/DispatchLoadingDocuments.vue' , import . meta . url ), 'utf8' )). match ( /<script>([\s\S]*?)<\/script>/ )[ 1 ]
const data = extra => ({ dispatchNo : 'PC/1' , recordVersion : '0' , dispatchRecordVersion : '3' , stage : 'AFTER_LOADING' , editable : true , complete : false , photos : [], ... extra })
const photo = { slot : 'LOADING_SCENE' , fileId : '1001' , fileName : 'loading.png' , contentType : 'image/png' }
function component ( overrides = {}) {
const values = new Map (), events = []
globalThis . uni = { setStorageSync : ( key , value ) => values . set ( key , value ), removeStorageSync : key => values . delete ( key ), getStorageSync : key => values . get ( key ), chooseImage : async () => ({ tempFilePaths : [ 'local-image' ], tempFiles : [{ size : 123 }] }), showToast () {} }
2026-10-26 17:12:38 +08:00
const dependencies = { session : { currentUser : { id : 'user' , warehouseId : 'warehouse' } }, loadingPhotoSlots , loadingPhotoLabels , loadingPhotoLimit , toDispatchError : cause => cause ,
2026-10-21 17:27:13 +08:00
getLoadingDocuments : async () => data (), uploadLoadingPhoto : async () => photo , recoverLoadingPhoto : async () => photo ,
bindLoadingPhoto : async () => data ({ recordVersion : '1' , photos : [ photo ] }), readLoadingPhoto : async () => 'downloaded-image' , ... overrides }
const definition = new Function (... Object . keys ( dependencies ), source . replace ( /^import .*$/gm , '' ). replace ( 'export default' , 'return' ))(... Object . values ( dependencies ))
const state = { ... definition . data . call ({ dispatchNo : 'PC/1' }), dispatchNo : 'PC/1' , $emit : (... args ) => events . push ( args ) }
for ( const [ key , method ] of Object . entries ( definition . methods )) state [ key ] = method . bind ( state )
for ( const [ key , getter ] of Object . entries ( definition . computed )) Object . defineProperty ( state , key , { get : () => getter . call ( state ) })
return { state , events , values , definition }
}
2026-10-22 23:06:06 +08:00
test ( 'five existing slots carry their business names into upload, replace and preview controls' , async () => {
const { state } = component ()
assert . deepEqual ( state . slots , [ 'LOADING_SCENE' , 'LOADING_LIST' , 'PRE_DEPARTURE_CHECK' , 'DRIVER_LICENSE' , 'RECEIPT' ])
2026-10-26 17:12:38 +08:00
assert . deepEqual ( state . slotLabels , { LOADING_SCENE : '司机驾驶证' , LOADING_LIST : '车牌照片' , PRE_DEPARTURE_CHECK : '装车完成' , DRIVER_LICENSE : '封车照片' , RECEIPT : 'BOL司机签字' })
2026-10-22 23:06:06 +08:00
const template = ( await readFile ( new URL ( '../src/components/DispatchLoadingDocuments.vue' , import . meta . url ), 'utf8' )). split ( '<script>' )[ 0 ]
assert . match ( template , /class="photo-label">\{\{ slotLabels\[slot\] \}\}/ )
2026-10-26 17:12:38 +08:00
assert . match ( template , /@tap="choose\(slot, photo.fileId\)"/ )
2026-10-22 23:06:06 +08:00
assert . match ( template , /:aria-label="`预览\$\{slotLabels\[slot\]\}`"/ )
assert . match ( template , /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/ )
assert . doesNotMatch ( template , /装车照片 \{\{ index \+ 1 \}\}/ )
})
2026-10-21 17:27:13 +08:00
test ( 'PDA reads and binds through its own namespace without customer or warehouse input' , async () => {
const calls = []; session . apiBase = '/gateway-api' ; session . token = 'test-token'
globalThis . uni = { request ( options ) { calls . push ( options ); options . success ({ statusCode : 200 , data : { code : '200' , data : data () } }) } }
await getLoadingDocuments ( 'PC/1' )
const body = { recordVersion : '9007199254740993' , idempotencyKey : 'stable-key' , fileId : '1001' }
await bindLoadingPhoto ( 'PC/1' , 'LOADING_SCENE' , body )
assert . match ( calls [ 0 ]. url , /\/pda\/v1\/last-leg\/dispatches\/PC%2F1\/loading-documents$/ )
assert . equal ( calls [ 1 ]. method , 'PUT' ); assert . deepEqual ( calls [ 1 ]. data , body )
})
test ( 'PDA upload preserves the original request key through the authenticated upload helper' , async () => {
const calls = []; session . apiBase = '/gateway-api' ; session . token = 'test-token'
globalThis . uni = { uploadFile ( options ) { calls . push ( options ); options . success ({ statusCode : 200 , data : JSON . stringify ({ code : '200' , data : photo }) }) } }
await uploadLoadingPhoto ( 'PC/1' , 'LOADING_SCENE' , 'local-image' , 'same-key' )
assert . match ( calls [ 0 ]. url , /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key$/ )
assert . equal ( calls [ 0 ]. filePath , 'local-image' )
})
2026-10-22 23:06:06 +08:00
2026-10-21 17:27:13 +08:00
test ( 'wrong dispatch, duplicate slots and invalid versions never become saved facts' , () => {
assert . throws (() => normalizeLoadingDocuments ( data (), 'PC/2' ), /不一致/ )
assert . throws (() => normalizeLoadingDocuments ( data ({ photos : [ photo , photo ] }), 'PC/1' ), /重复/ )
assert . throws (() => normalizeLoadingDocuments ( data ({ recordVersion : null }), 'PC/1' ), /不一致/ )
})
2026-10-22 23:06:06 +08:00
for ( const failure of [{ network : true , message : 'timeout' }, { code : 'A0409' , message : 'conflict' }, { code : 'A0422' , message : 'invalid' }]) test ( 'upload failure leaves saved photos and unlocks current selection: ' + failure . message , async () => {
const uploads = []; let reads = 0 , choice = 0
const { state , values , events } = component ({ getLoadingDocuments : async () => { reads ++ ; return data ({ photos : [ photo ] }) }, uploadLoadingPhoto : async (... args ) => { uploads . push ( args ); throw failure } })
uni . chooseImage = async () => ({ tempFilePaths : [ 'image-' + ++ choice ] })
await state . load (); await state . choose ( photo . slot )
assert . equal ( state . busy , false ); assert . equal ( values . size , 0 ); assert . equal ( reads , 1 )
assert . equal ( state . snapshot . photos [ 0 ]. fileId , photo . fileId )
await state . choose ( photo . slot )
assert . equal ( uploads . length , 2 ); assert . equal ( uploads [ 1 ][ 2 ], 'image-2' ); assert . notEqual ( uploads [ 0 ][ 3 ], uploads [ 1 ][ 3 ])
assert . deepEqual ( events . at ( - 1 ), [ 'busy-change' , false ])
2026-10-21 19:44:32 +08:00
})
2026-10-22 23:06:06 +08:00
test ( 'binding failure releases immediately and the next selection uses a new file, key and current version' , async () => {
const binds = []; let uploads = 0 , version = '0' , reads = 0
const { state , values , events } = component ({ getLoadingDocuments : async () => { reads ++ ; return data ({ recordVersion : version }) }, uploadLoadingPhoto : async () => ({ ... photo , fileId : String ( ++ uploads ) }), bindLoadingPhoto : async ( _no , _slot , body ) => { binds . push ( body ); if ( binds . length === 1 ) throw { network : true }; return data ({ photos : [ photo ] }) } })
await state . load (); await state . choose ( photo . slot )
2026-10-26 17:12:38 +08:00
assert . equal ( state . busy , false ); assert . equal ( reads , 3 ); assert . equal ( values . size , 0 )
2026-10-22 23:06:06 +08:00
version = '7' ; await state . choose ( photo . slot )
assert . equal ( uploads , 2 ); assert . equal ( binds [ 1 ]. recordVersion , '7' ); assert . notEqual ( binds [ 1 ]. fileId , binds [ 0 ]. fileId )
assert . notEqual ( binds [ 1 ]. idempotencyKey , binds [ 0 ]. idempotencyKey ); assert . equal ( events . filter ( event => event [ 0 ] === 'saved' ). length , 1 )
})
2026-10-26 17:12:38 +08:00
test ( 'a lost binding response immediately reloads server success without another write' , async () => {
2026-10-22 23:06:06 +08:00
let binds = 0 , latest = data ()
const { state } = component ({ getLoadingDocuments : async () => latest , bindLoadingPhoto : async () => { binds ++ ; latest = data ({ editable : false , complete : true , stage : 'DELIVERING' , photos : [ photo ] }); throw { network : true } } })
await state . load (); await state . choose ( photo . slot ); assert . equal ( state . busy , false )
2026-10-26 17:12:38 +08:00
assert . equal ( state . snapshot . stage , 'DELIVERING' ); assert . equal ( state . editable , false )
2026-10-22 23:06:06 +08:00
await state . load (); assert . equal ( state . snapshot . stage , 'DELIVERING' ); assert . equal ( binds , 1 ); assert . equal ( state . editable , false )
})
2026-10-26 17:12:38 +08:00
test ( 'failed reconciliation blocks further uploads until an explicit refresh succeeds' , async () => {
let reads = 0 , uploads = 0
const { state } = component ({ getLoadingDocuments : async () => { if ( ++ reads === 3 ) throw new Error ( '读取失败' ); return data () },
uploadLoadingPhoto : async () => { uploads ++ ; return photo }, bindLoadingPhoto : async () => { throw new Error ( '保存超时' ) } })
await state . load (); await state . choose ( photo . slot )
assert . equal ( state . busy , false ); assert . equal ( state . needsRefresh , true ); assert . equal ( state . editable , false )
assert . match ( state . error . message , /保存超时/ )
await state . choose ( photo . slot ); assert . equal ( uploads , 1 )
await state . load (); assert . equal ( state . needsRefresh , false ); assert . equal ( state . editable , true )
})
2026-10-22 23:06:06 +08:00
test ( 'in-flight upload blocks duplicate clicks and unmount releases the parent busy flag' , async () => {
let resolveUpload , uploads = 0
const { state , events , definition } = component ({ uploadLoadingPhoto : () => { uploads ++ ; return new Promise ( resolve => { resolveUpload = resolve }) } })
await state . load (); const first = state . choose ( photo . slot )
while ( ! resolveUpload ) await new Promise ( resolve => setImmediate ( resolve ))
await state . choose ( photo . slot ); assert . equal ( uploads , 1 )
definition . beforeUnmount . call ( state ); assert . deepEqual ( events . at ( - 1 ), [ 'busy-change' , false ])
resolveUpload ( photo ); await first ; assert . equal ( events . some ( event => event [ 0 ] === 'saved' ), false )
2026-10-21 17:27:13 +08:00
})
2026-10-26 17:12:38 +08:00
test ( 'normalization accepts five distinct photos per category but rejects a sixth' , () => {
const photos = Array . from ({ length : 5 }, ( _ , index ) => ({ ... photo , fileId : String ( index + 1 ) }))
assert . equal ( normalizeLoadingDocuments ( data ({ photos }), 'PC/1' ). photos . length , 5 )
assert . throws (() => normalizeLoadingDocuments ( data ({ photos : [... photos , { ... photo , fileId : '6' }] }), 'PC/1' ), /最多5张/ )
})
test ( 'multi-select appends every image using fresh versions and counts categories rather than files' , async () => {
let latest = data (), count = 0 , selectedCount
const binds = []
const { state , events } = component ({ getLoadingDocuments : async () => latest ,
uploadLoadingPhoto : async () => ({ ... photo , fileId : String ( ++ count ) }),
bindLoadingPhoto : async ( _no , _slot , body ) => {
binds . push ( body )
latest = data ({ recordVersion : String ( binds . length ), photos : [... latest . photos , { ... photo , fileId : body . fileId }] }); return latest
} })
uni . chooseImage = async options => { selectedCount = options . count ; return { tempFilePaths : [ 'a' , 'b' , 'c' , 'd' , 'e' ] } }
await state . load (); await state . choose ( photo . slot )
assert . equal ( selectedCount , 5 ); assert . equal ( state . snapshot . photos . length , 5 ); assert . equal ( state . completedCategories , 1 )
assert . deepEqual ( binds . map ( body => body . recordVersion ), [ '0' , '1' , '2' , '3' , '4' ])
assert . equal ( new Set ( binds . map ( body => body . idempotencyKey )). size , 5 )
await state . choose ( photo . slot ); assert . equal ( count , 5 )
assert . equal ( events . filter ( event => event [ 0 ] === 'saved' ). length , 1 )
})
test ( 'replacement sends the selected old file and retains the other four photos' , async () => {
let latest = data ({ photos : Array . from ({ length : 5 }, ( _ , index ) => ({ ... photo , fileId : String ( index + 1 ) })) })
let selectedCount , request
const { state } = component ({ getLoadingDocuments : async () => latest , uploadLoadingPhoto : async () => ({ ... photo , fileId : '6' }),
bindLoadingPhoto : async ( _no , _slot , body ) => { request = body ; latest = data ({ recordVersion : '1' , photos : latest . photos . map ( item => item . fileId === body . replaceFileId ? { ... photo , fileId : body . fileId } : item ) }); return latest } })
uni . chooseImage = async options => { selectedCount = options . count ; return { tempFilePaths : [ 'replacement' ] } }
await state . load (); await state . choose ( photo . slot , '3' )
assert . equal ( selectedCount , 1 ); assert . equal ( request . replaceFileId , '3' )
assert . deepEqual ( state . snapshot . photos . map ( item => item . fileId ), [ '1' , '2' , '6' , '4' , '5' ])
})
test ( 'a partial batch failure preserves successful photos and notifies the task once' , async () => {
let latest = data (), uploads = 0
const { state , events } = component ({ getLoadingDocuments : async () => latest ,
uploadLoadingPhoto : async () => { if ( ++ uploads === 2 ) throw new Error ( '网络中断' ); return photo },
bindLoadingPhoto : async () => { latest = data ({ recordVersion : '1' , photos : [ photo ] }); return latest } })
uni . chooseImage = async () => ({ tempFilePaths : [ 'first' , 'failed' , 'unattempted' ] })
await state . load (); await state . choose ( photo . slot )
assert . equal ( uploads , 2 ); assert . equal ( state . busy , false ); assert . match ( state . error . message , /网络中断/ )
assert . deepEqual ( state . snapshot . photos , [ photo ]); assert . equal ( events . filter ( event => event [ 0 ] === 'saved' ). length , 1 )
})