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'
import { loadingPhotoSlots , normalizeLoadingDocuments , getLoadingDocuments , bindLoadingPhoto , uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
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 () {} }
const dependencies = { session : { currentUser : { id : 'user' , warehouseId : 'warehouse' } }, loadingPhotoSlots , toDispatchError : cause => cause ,
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' ])
assert . deepEqual ( state . slotLabels , { LOADING_SCENE : '装车现场' , LOADING_LIST : '装车清单' , PRE_DEPARTURE_CHECK : '发车前检查' , DRIVER_LICENSE : '驾驶证' , RECEIPT : '回执' })
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\] \}\}/ )
assert . match ( template , /:aria-label="`\$\{snapshot\?\.photos.some\(item => item.slot === slot\) \? '更换' : '上传'\}\$\{slotLabels\[slot\]\}`"/ )
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 )
assert . equal ( state . busy , false ); assert . equal ( reads , 2 ); assert . equal ( values . size , 0 )
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 )
})
test ( 'manual refresh after a lost binding response reflects server success without another write' , async () => {
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 )
await state . load (); assert . equal ( state . snapshot . stage , 'DELIVERING' ); assert . equal ( binds , 1 ); assert . equal ( state . editable , false )
})
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
})