1086 lines
38 KiB
TypeScript
1086 lines
38 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import { effectScope, nextTick, ref, watch } from 'vue'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
import * as brand from '../src/utils/crm-product-brand'
|
|
import { filterProductsByAllowedProductIds } from '../src/utils/pl-plan-contract-product'
|
|
|
|
const source = readFileSync(
|
|
new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
const descriptor = parse(source).descriptor
|
|
const ast = ts.createSourceFile(
|
|
'form.ts',
|
|
descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
function declaration(name: string) {
|
|
return ast.statements
|
|
.find(
|
|
(node) =>
|
|
(ts.isFunctionDeclaration(node) && node.name?.text === name) ||
|
|
(ts.isVariableStatement(node) &&
|
|
node.declarationList.declarations.some((d) => d.name.getText(ast) === name))
|
|
)
|
|
?.getText(ast)
|
|
}
|
|
function deferred<T = any>() {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason: any) => void
|
|
const promise = new Promise<T>((yes, no) => {
|
|
resolve = yes
|
|
reject = no
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
function harness(
|
|
options: {
|
|
page?: boolean
|
|
copy?: boolean
|
|
newPlan?: boolean
|
|
get?: any
|
|
master?: any
|
|
contract?: any
|
|
enrich?: any
|
|
realDependencies?: boolean
|
|
realArrange?: boolean
|
|
overrides?: Record<string, any>
|
|
} = {}
|
|
) {
|
|
const writes: any[] = []
|
|
const events: any[] = []
|
|
const invalidations: string[] = []
|
|
const noop = () => {}
|
|
const bindings: Record<string, any> = {
|
|
ref,
|
|
Error,
|
|
String,
|
|
Object,
|
|
Promise,
|
|
Set,
|
|
Number,
|
|
props: {
|
|
planId: options.copy || options.newPlan ? undefined : 'plan-1',
|
|
copyFromPlanId: options.copy ? 'plan-1' : undefined
|
|
},
|
|
isPageVariant: ref(options.page !== false),
|
|
isEdit: ref(!options.copy && !options.newPlan),
|
|
detailLoading: ref(false),
|
|
submitLoading: ref(false),
|
|
suppressAddressMutex: ref(false),
|
|
suppressStopProductSync: ref(false),
|
|
formData: { belongsCompany: 'shanxia', createDate: '2026-10-21', products: [] },
|
|
loadForm: ref({ products: [], contracts: [] }),
|
|
deliveryForms: ref([]),
|
|
loadedPlanSnapshot: ref(null),
|
|
loadedTransportStatus: ref('pending'),
|
|
loadedFollowStatus: ref(''),
|
|
selectedDriverId: ref(''),
|
|
selectedEscortId: ref(''),
|
|
vehicleBundle: ref(null),
|
|
contractProductIdsCache: ref({}),
|
|
contractProductRowsCache: ref({}),
|
|
contractLinesCache: ref({}),
|
|
loadContractProductIds: ref([]),
|
|
vehicleList: ref([]),
|
|
carrierList: ref([]),
|
|
driverList: ref([]),
|
|
escortList: ref([]),
|
|
customerOptions: ref([]),
|
|
customerOptionsLoading: ref(false),
|
|
vehicleLoading: ref(false),
|
|
footerMoreOpen: ref(false),
|
|
planApi: {
|
|
get:
|
|
options.get ||
|
|
(async () => ({
|
|
data: { id: 'plan-1', belongsCompany: 'shanxia', remark: '原计划', products: [] }
|
|
})),
|
|
update: async (...args: any[]) => {
|
|
writes.push(['update', ...args])
|
|
return { id: 'plan-1' }
|
|
},
|
|
createFull: async (...args: any[]) => {
|
|
writes.push(['create', ...args])
|
|
return { id: 'new-plan' }
|
|
}
|
|
},
|
|
invalidateContractDetailCache: (id: string) => invalidations.push(id),
|
|
getContractDetailCached: options.contract || (async () => ({ id: 'c1', products: [] })),
|
|
enrichPlanProductsHazardFromCrm: options.enrich || (async (rows: any) => rows),
|
|
enrichPlanProductLinesAlias: async (rows: any) => rows,
|
|
mapPlanProductToFormRow: (row: any) => ({ ...row }),
|
|
mapPlanProductToCopyFormRow: (row: any) => ({ ...row, id: undefined }),
|
|
collectStopContractIds: (rows: any[]) => rows.map((row) => row.contractId),
|
|
collectContractProductRows: (rows: any) => rows,
|
|
collectContractProductIds: (rows: any[]) => rows.map((row) => row.productId),
|
|
unionContractProductIds: (cache: any, ids: string[]) => ids.flatMap((id) => cache[id] || []),
|
|
resetForm: () => {
|
|
bindings.formData.remark = ''
|
|
bindings.formData.products = []
|
|
bindings.loadedPlanSnapshot.value = null
|
|
},
|
|
applyStopsFromPlan: (data: any) => {
|
|
bindings.loadForm.value = data.load || { products: [], contracts: [] }
|
|
bindings.deliveryForms.value = data.deliveries || []
|
|
},
|
|
syncCommittedPartyIds: noop,
|
|
bootstrapStopsFromPlanProducts: noop,
|
|
syncSelectedCustomerOptions: noop,
|
|
todayYmd: () => '2026-10-21',
|
|
formatDate: () => '',
|
|
resolveCurrentUserId: () => 'user',
|
|
resolveCurrentUserName: () => '用户',
|
|
freightCalc: { resetFreightCalc: noop, freightAmount: ref(null) },
|
|
ensureSelectedVehicleOption: async () => {},
|
|
ensureSelectedDriverOption: async () => {},
|
|
ensureSelectedEscortOption: async () => {},
|
|
ensureSelectedCarrierOption: async () => {},
|
|
handleVehicleChange: async () => {},
|
|
restoreFreightFromTransport: async () => {},
|
|
reapplyStopDibangByVehiclePlate: noop,
|
|
nextTick: async () => {},
|
|
ElMessage: { error: noop, warning: noop, success: noop },
|
|
emit: (...args: any[]) => events.push(args),
|
|
innerVisible: ref(true),
|
|
basicFormRef: ref({ validate: async () => true, validateField: async () => {} }),
|
|
mirrorPlanProductsFromLoad: noop,
|
|
buildPlanCommonPayload: () => ({
|
|
remark: bindings.formData.remark,
|
|
products: bindings.formData.products
|
|
}),
|
|
pickBxData: (data: any) => data.data ?? data,
|
|
validateBusinessRules: () => true,
|
|
validateSingleContractPerStop: () => true,
|
|
validateContractPackageSpecs: () => true,
|
|
validateStopContractsRequired: () => true,
|
|
getArrangePrerequisiteGaps: () => [],
|
|
validateStopProductsForArrange: () => true,
|
|
showPlanSaveWarnings: noop,
|
|
performArrangeAfterSave: async (id: string) => {
|
|
writes.push(['arrange', id])
|
|
return true
|
|
},
|
|
loadMasterData: options.master || (async () => {}),
|
|
vehicleApi: { list: async () => [], get: async (id: string) => ({ id }) },
|
|
carrierApi: { list: async () => [], get: async (id: string) => ({ id }) },
|
|
personnelApi: { list: async () => [], get: async (id: string) => ({ id }) },
|
|
fetchCustomerOptions: async () => [],
|
|
extractCustomerOptions: (data: any) => data,
|
|
pickBxList: (data: any) => data.data ?? data,
|
|
searchVehicles: async () => {},
|
|
searchCarriers: async () => {},
|
|
searchDrivers: async () => {},
|
|
searchEscorts: async () => {},
|
|
loadDefaultCustomerOptions: async () => {},
|
|
loadVehicleViewBundle: async () => ({ detail: {}, personnelList: [] }),
|
|
transportApi: { get: async () => ({}) },
|
|
resolveDefaultDriver: noop,
|
|
resolveDefaultEscort: noop,
|
|
buildMissingHazardCertReadyMessage: () => '',
|
|
ElMessageBox: { alert: async () => {} },
|
|
planHasAnyDibangSelected: () => false,
|
|
loadArrangeHistoricalFlags: async () => ({}),
|
|
confirmBeforePlanArrange: async () => {},
|
|
buildArrangePayloadFromPlanForm: () => ({}),
|
|
submitPlanArrange: async (id: string) => {
|
|
writes.push(['arrange', id])
|
|
},
|
|
getUnitLabel: () => '',
|
|
...options.overrides
|
|
}
|
|
const names = [
|
|
'pageReady',
|
|
'pageLoadError',
|
|
'pageLoadGeneration',
|
|
'pageDisposed',
|
|
'isPageLoadCurrent',
|
|
'assertPageLoadCurrent',
|
|
'failPageLoad',
|
|
'invalidatePageLoad',
|
|
'validateLoadedPagePlan',
|
|
'handleOpened',
|
|
'loadPlanForEdit',
|
|
'loadPlanForCopy',
|
|
'fetchContractProductIds',
|
|
'prefetchStopContractProducts',
|
|
'handleSubmit',
|
|
'isPageSubmitCurrent',
|
|
'pageLoadErrorMessage'
|
|
]
|
|
names.push('ensurePageSelectedOption')
|
|
if (options.realDependencies) {
|
|
names.push('loadMasterData', 'handleVehicleChange', 'restoreFreightFromTransport')
|
|
for (const name of ['loadMasterData', 'handleVehicleChange', 'restoreFreightFromTransport'])
|
|
delete bindings[name]
|
|
}
|
|
if (options.realArrange) {
|
|
names.push('performArrangeAfterSave')
|
|
delete bindings.performArrangeAfterSave
|
|
}
|
|
const present = names.filter((name) => declaration(name))
|
|
const code = ts.transpile(present.map((name) => declaration(name)).join('\n'), {
|
|
target: ts.ScriptTarget.ESNext
|
|
})
|
|
const result = new Function(
|
|
...Object.keys(bindings),
|
|
code + '; return {' + present.join(',') + '}'
|
|
)(...Object.values(bindings))
|
|
return { ...bindings, ...result, writes, events, invalidations }
|
|
}
|
|
async function blocked(h: ReturnType<typeof harness>) {
|
|
for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange']) await h.handleSubmit(mode)
|
|
assert.deepEqual(h.writes, [], 'failed initialization must never write or arrange')
|
|
}
|
|
for (const status of [404, 500])
|
|
test(`mobile edit GET ${status} stays blocked`, async () => {
|
|
const h = harness({
|
|
get: async () => {
|
|
throw new Error(`HTTP ${status}`)
|
|
}
|
|
})
|
|
await h.handleOpened().catch(() => {})
|
|
await blocked(h)
|
|
assert.equal(h.pageReady.value, false)
|
|
assert.match(h.pageLoadError.value, /404|500/)
|
|
assert.equal(h.detailLoading.value, false)
|
|
})
|
|
test('mobile copy GET failure remains visible and blocked', async () => {
|
|
const h = harness({
|
|
copy: true,
|
|
get: async () => {
|
|
throw new Error('copy failed')
|
|
}
|
|
})
|
|
await h.handleOpened().catch(() => {})
|
|
await blocked(h)
|
|
assert.ok(h.pageLoadError.value)
|
|
assert.equal(
|
|
h.events.some((event) => event[0] === 'update:visible'),
|
|
false
|
|
)
|
|
})
|
|
for (const data of [{}, { id: 'different' }, null])
|
|
test(`mobile rejects invalid plan ${JSON.stringify(data)}`, async () => {
|
|
const h = harness({ get: async () => ({ data }) })
|
|
await h.handleOpened().catch(() => {})
|
|
await blocked(h)
|
|
assert.ok(h.pageLoadError.value)
|
|
})
|
|
test('new mobile base dependency failure cannot create', async () => {
|
|
const h = harness({
|
|
newPlan: true,
|
|
master: async () => {
|
|
throw new Error('base failed')
|
|
}
|
|
})
|
|
await h.handleOpened().catch(() => {})
|
|
await blocked(h)
|
|
})
|
|
test('contract failure preserves historical products, does not cache empty, retry recovers and saves original payload', async () => {
|
|
let failed = true
|
|
const h = harness({
|
|
get: async () => ({
|
|
id: 'plan-1',
|
|
belongsCompany: 'shanxia',
|
|
remark: '原计划',
|
|
products: [{ productId: 'p1' }],
|
|
load: { contracts: [{ contractId: 'c1' }], products: [{ productId: 'p1', planQty: 12 }] }
|
|
}),
|
|
contract: async () => {
|
|
if (failed) throw new Error('contract failed')
|
|
return { id: 'c1', products: [{ productId: 'p1' }] }
|
|
}
|
|
})
|
|
await h.handleOpened().catch(() => {})
|
|
await blocked(h)
|
|
assert.deepEqual(h.contractProductIdsCache.value, {})
|
|
assert.equal(h.loadForm.value.products[0].planQty, 12)
|
|
failed = false
|
|
await h.handleOpened()
|
|
assert.equal(h.pageReady.value, true)
|
|
await h.handleSubmit('save')
|
|
assert.deepEqual(h.writes, [
|
|
['update', 'plan-1', { remark: '原计划', products: [{ productId: 'p1' }], saveMode: 'formal' }]
|
|
])
|
|
})
|
|
for (const lateError of [false, true])
|
|
test(`retry ignores old ${lateError ? 'error' : 'success'} and finally`, async () => {
|
|
const old = deferred()
|
|
const fresh = deferred()
|
|
let count = 0
|
|
const h = harness({ get: () => (++count === 1 ? old.promise : fresh.promise) })
|
|
const first = h.handleOpened().catch(() => {})
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
const second = h.handleOpened()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
if (lateError) old.reject(new Error('old error'))
|
|
else old.resolve({ id: 'plan-1', remark: 'stale' })
|
|
await first
|
|
assert.equal(h.detailLoading.value, true)
|
|
assert.equal(h.formData.remark, '')
|
|
assert.equal(h.pageLoadError.value, '')
|
|
fresh.resolve({ id: 'plan-1', remark: 'fresh', products: [] })
|
|
await second
|
|
assert.equal(h.formData.remark, 'fresh')
|
|
assert.equal(h.pageReady.value, true)
|
|
})
|
|
test('unmount invalidates pending detail without late ref writes or loading events', async () => {
|
|
const request = deferred()
|
|
const h = harness({ get: () => request.promise })
|
|
const work = h.handleOpened()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
h.invalidatePageLoad()
|
|
const before = JSON.stringify(h.formData)
|
|
request.resolve({ id: 'plan-1', remark: 'stale' })
|
|
await work
|
|
assert.equal(JSON.stringify(h.formData), before)
|
|
assert.equal(h.pageReady.value, false)
|
|
})
|
|
test('desktop retains copy failure close, failed contract empty cache and draft save behavior', async () => {
|
|
const h = harness({
|
|
page: false,
|
|
copy: true,
|
|
get: async () => {
|
|
throw new Error('404')
|
|
},
|
|
contract: async () => {
|
|
throw new Error('offline')
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
assert.ok(h.events.some((event) => event[0] === 'update:visible' && event[1] === false))
|
|
assert.deepEqual(await h.fetchContractProductIds('c1'), [])
|
|
assert.deepEqual(h.contractProductIdsCache.value, { c1: [] })
|
|
await h.handleSubmit('draft')
|
|
assert.equal(h.writes[0][0], 'create')
|
|
})
|
|
test('page template gates form and write actions on readiness and exposes retry/return', () => {
|
|
assert.match(descriptor.template!.content, /v-if="isPageVariant && pageLoadError"/)
|
|
assert.match(descriptor.template!.content, /v-show="!isPageVariant \|\| pageReady"/)
|
|
assert.match(descriptor.template!.content, /v-if="isPageVariant && pageReady"/)
|
|
assert.match(descriptor.template!.content, /重新加载/)
|
|
assert.match(source, /onBeforeUnmount\(invalidatePageLoad\)/)
|
|
})
|
|
|
|
for (const dependency of ['vehicleApi', 'carrierApi', 'personnelApi', 'fetchCustomerOptions']) {
|
|
test(`real mobile base loader fails closed for ${dependency}`, async () => {
|
|
const fail = async () => {
|
|
throw new Error(`${dependency} failed`)
|
|
}
|
|
const overrides =
|
|
dependency === 'fetchCustomerOptions'
|
|
? { fetchCustomerOptions: fail }
|
|
: { [dependency]: { list: fail } }
|
|
const h = harness({ newPlan: true, realDependencies: true, overrides })
|
|
await h.handleOpened()
|
|
await blocked(h)
|
|
assert.match(h.pageLoadError.value, /failed/)
|
|
})
|
|
}
|
|
test('late base dependency cannot replace new option lists', async () => {
|
|
const old = deferred()
|
|
let count = 0
|
|
const h = harness({
|
|
newPlan: true,
|
|
realDependencies: true,
|
|
overrides: {
|
|
vehicleApi: { list: () => (++count === 1 ? old.promise : Promise.resolve([{ id: 'fresh' }])) }
|
|
}
|
|
})
|
|
const first = h.handleOpened()
|
|
await h.handleOpened()
|
|
old.resolve([{ id: 'old' }])
|
|
await first
|
|
assert.deepEqual(h.vehicleList.value, [{ id: 'fresh' }])
|
|
assert.equal(h.pageReady.value, true)
|
|
})
|
|
test('late product enrichment cannot overwrite retry products or release new suppression flags', async () => {
|
|
const old = deferred()
|
|
const fresh = deferred()
|
|
let count = 0
|
|
const h = harness({ enrich: () => (++count === 1 ? old.promise : fresh.promise) })
|
|
const first = h.handleOpened()
|
|
while (count < 1) await Promise.resolve()
|
|
const second = h.handleOpened()
|
|
while (count < 2) await Promise.resolve()
|
|
old.resolve([{ productId: 'old' }])
|
|
await first
|
|
assert.equal(h.suppressStopProductSync.value, true)
|
|
assert.deepEqual(h.formData.products, [])
|
|
fresh.resolve([{ productId: 'fresh' }])
|
|
await second
|
|
assert.deepEqual(h.formData.products, [{ productId: 'fresh' }])
|
|
})
|
|
test('late vehicle bundle cannot overwrite retry refs or clear the new vehicle loading flag', async () => {
|
|
const old = deferred()
|
|
const fresh = deferred()
|
|
let count = 0
|
|
const h = harness({
|
|
realDependencies: true,
|
|
get: async () => ({ id: 'plan-1', vehicleId: 'v1' }),
|
|
overrides: { loadVehicleViewBundle: () => (++count === 1 ? old.promise : fresh.promise) }
|
|
})
|
|
const first = h.handleOpened()
|
|
while (count < 1) await Promise.resolve()
|
|
const second = h.handleOpened()
|
|
while (count < 2) await Promise.resolve()
|
|
old.resolve({ detail: { carrierId: 'old' }, personnelList: [] })
|
|
await first
|
|
assert.equal(h.vehicleLoading.value, true)
|
|
assert.equal(h.vehicleBundle.value, null)
|
|
fresh.resolve({ detail: { carrierId: 'fresh' }, personnelList: [] })
|
|
await second
|
|
assert.equal(h.formData.carrierId, 'fresh')
|
|
})
|
|
test('late contract success cannot populate the current generation cache', async () => {
|
|
const old = deferred()
|
|
const h = harness({ contract: () => old.promise })
|
|
const fetch = h.fetchContractProductIds('c1').catch(() => {})
|
|
await h.handleOpened()
|
|
old.resolve({ products: [{ productId: 'old' }] })
|
|
await fetch
|
|
assert.deepEqual(h.contractProductIdsCache.value, {})
|
|
assert.equal(h.pageLoadError.value, '')
|
|
})
|
|
test('successful mobile copy clears source ids and transport people before creating', async () => {
|
|
const original = {
|
|
id: 'plan-1',
|
|
belongsCompany: 'shanxia',
|
|
vehicleId: 'v1',
|
|
driverId: 'd1',
|
|
escortId: 'e1',
|
|
load: {
|
|
id: 'load-1',
|
|
contracts: [],
|
|
products: [{ id: 'line-1', planProductId: 'product-row-1' }]
|
|
},
|
|
deliveries: [
|
|
{ id: 'stop-1', contracts: [], products: [{ id: 'line-2', planProductId: 'product-row-2' }] }
|
|
]
|
|
}
|
|
const h = harness({ copy: true, get: async () => structuredClone(original) })
|
|
await h.handleOpened()
|
|
assert.equal(h.pageReady.value, true)
|
|
assert.equal(h.loadForm.value.id, undefined)
|
|
assert.equal(h.loadForm.value.products[0].planProductId, undefined)
|
|
assert.equal(h.deliveryForms.value[0].id, undefined)
|
|
assert.equal(h.deliveryForms.value[0].products[0].id, undefined)
|
|
assert.equal(h.formData.vehicleId, '')
|
|
assert.equal(h.selectedDriverId.value, '')
|
|
assert.equal(h.selectedEscortId.value, '')
|
|
await h.handleSubmit('draft')
|
|
assert.equal(h.writes[0][0], 'create')
|
|
assert.equal(
|
|
h.writes.some((row) => row[0] === 'update'),
|
|
false
|
|
)
|
|
})
|
|
test('draft keeps minimal fields and formal save still requires original validation', async () => {
|
|
const h = harness({ newPlan: true })
|
|
await h.handleOpened()
|
|
h.basicFormRef.value.validate = async () => false
|
|
await h.handleSubmit('save')
|
|
assert.deepEqual(h.writes, [])
|
|
await h.handleSubmit('draft')
|
|
assert.equal(h.writes[0][0], 'create')
|
|
})
|
|
test('desktop edit GET error still propagates and clears the loading state', async () => {
|
|
const h = harness({
|
|
page: false,
|
|
get: async () => {
|
|
throw new Error('desktop failure')
|
|
}
|
|
})
|
|
await assert.rejects(h.handleOpened(), /desktop failure/)
|
|
assert.equal(h.detailLoading.value, false)
|
|
assert.equal(h.pageLoadError.value, '')
|
|
})
|
|
|
|
test('empty dependency error still provides a visible retry state', async () => {
|
|
const h = harness({
|
|
master: async () => {
|
|
throw new Error('')
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
assert.ok(h.pageLoadError.value)
|
|
})
|
|
|
|
test('new initialization resets vehicle loading left by an invalidated generation', async () => {
|
|
const h = harness({ newPlan: true })
|
|
h.vehicleLoading.value = true
|
|
await h.handleOpened()
|
|
assert.equal(h.vehicleLoading.value, false)
|
|
})
|
|
|
|
for (const kind of ['driver', 'transport'])
|
|
test(`${kind} refill failure prevents saving mobile details`, async () => {
|
|
const fail = async () => {
|
|
throw new Error(`${kind} failed`)
|
|
}
|
|
const h = harness({
|
|
realDependencies: true,
|
|
get: async () => ({
|
|
id: 'plan-1',
|
|
driverId: kind === 'driver' ? 'd1' : '',
|
|
transportId: kind === 'transport' ? 't1' : ''
|
|
}),
|
|
overrides:
|
|
kind === 'driver'
|
|
? { personnelApi: { list: async () => [], get: fail } }
|
|
: { transportApi: { get: fail } }
|
|
})
|
|
await h.handleOpened()
|
|
await blocked(h)
|
|
assert.match(h.pageLoadError.value, /failed/)
|
|
})
|
|
|
|
function deliveryWatcher(h: ReturnType<typeof harness>) {
|
|
const watcher = ast.statements.find(
|
|
(node) =>
|
|
ts.isExpressionStatement(node) &&
|
|
node.getText(ast).startsWith('watch(') &&
|
|
node.getText(ast).includes('contractKey: collectStopContractIds')
|
|
)
|
|
assert.ok(watcher)
|
|
let callback!: (...args: any[]) => Promise<void>
|
|
const bindings = {
|
|
...h,
|
|
pageLoadGeneration: 1,
|
|
deliveryContractFetchSeqByIndex: {},
|
|
watch: (_source: any, cb: any) => {
|
|
callback = cb
|
|
}
|
|
}
|
|
new Function(
|
|
...Object.keys(bindings),
|
|
ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
|
|
)(...Object.values(bindings))
|
|
return callback
|
|
}
|
|
test('mobile removing a delivery contract still clears its products', async () => {
|
|
const h = harness()
|
|
await h.handleOpened()
|
|
h.deliveryForms.value = [{ products: [{ productId: 'p1' }], contracts: [] }]
|
|
await deliveryWatcher(h)([{ contractKey: '' }], [{ contractKey: 'c1' }])
|
|
assert.deepEqual(h.deliveryForms.value[0].products, [])
|
|
})
|
|
test('delivery contract metadata failure preserves historical products', async () => {
|
|
const h = harness({
|
|
contract: async () => {
|
|
throw new Error('offline')
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
h.deliveryForms.value = [{ products: [{ productId: 'p1' }], contracts: [{ contractId: 'c1' }] }]
|
|
await deliveryWatcher(h)([{ contractKey: 'c1' }], [{ contractKey: 'old' }])
|
|
assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'p1' }])
|
|
assert.equal(h.pageReady.value, false)
|
|
})
|
|
|
|
function enrichmentHelper(file: string, name: string, fetchProductDetail: any) {
|
|
const text = readFileSync(new URL(`../src/composables/${file}.ts`, import.meta.url), 'utf8')
|
|
const source = ts.createSourceFile('helper.ts', text, ts.ScriptTarget.Latest, true)
|
|
const declaration = source.statements.find(
|
|
(node) => ts.isFunctionDeclaration(node) && node.name?.text === name
|
|
)
|
|
assert.ok(declaration)
|
|
const code = ts.transpile(declaration.getText(source).replace(/^export\s+/, ''), {
|
|
target: ts.ScriptTarget.ESNext
|
|
})
|
|
return new Function('fetchProductDetail', ...Object.keys(brand), code + `;return ${name}`)(
|
|
fetchProductDetail,
|
|
...Object.values(brand)
|
|
)
|
|
}
|
|
for (const [file, name] of [
|
|
['usePlContractToPlan', 'enrichPlanProductLinesAlias'],
|
|
['usePlToxicProductCertCheck', 'enrichPlanProductsHazardFromCrm']
|
|
]) {
|
|
test(`${name} strict mode rejects failed product metadata while default preserves existing behavior`, async () => {
|
|
const helper = enrichmentHelper(file, name, async () => {
|
|
throw new Error('product offline')
|
|
})
|
|
const rows = [
|
|
{
|
|
productId: 'p1',
|
|
productName: '历史产品',
|
|
isToxic: 1,
|
|
brandMode: 'required',
|
|
brandId: 'b1',
|
|
brandName: '品牌'
|
|
}
|
|
]
|
|
const before = structuredClone(rows)
|
|
await assert.rejects(helper(rows, true), /product offline/)
|
|
assert.deepEqual(rows, before)
|
|
const fallback = await helper(structuredClone(rows))
|
|
assert.equal(fallback[0].brandId, 'b1')
|
|
assert.equal(fallback[0].isToxic, 1)
|
|
})
|
|
}
|
|
for (const copy of [false, true])
|
|
test(`mobile ${copy ? 'copy' : 'edit'} opts into strict product initialization`, async () => {
|
|
const strictArgs: unknown[] = []
|
|
const h = harness({
|
|
copy,
|
|
enrich: async (rows: any, strict: boolean) => {
|
|
strictArgs.push(strict)
|
|
return rows
|
|
},
|
|
overrides: {
|
|
enrichPlanProductLinesAlias: async (rows: any, strict: boolean) => {
|
|
strictArgs.push(strict)
|
|
return rows
|
|
}
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
assert.deepEqual(strictArgs, [true, true])
|
|
})
|
|
|
|
test('delivery slow contract completion cannot clear products selected after fast contract completion', async () => {
|
|
const slow = deferred()
|
|
const h = harness({
|
|
contract: (id: string) => (id === 'slow' ? slow.promise : Promise.resolve({ id, products: [] }))
|
|
})
|
|
await h.handleOpened()
|
|
const watchDelivery = deliveryWatcher(h)
|
|
h.deliveryForms.value = [
|
|
{ contracts: [{ contractId: 'slow' }], products: [{ productId: 'original' }] }
|
|
]
|
|
const first = watchDelivery([{ contractKey: 'slow' }], [{ contractKey: 'original' }])
|
|
h.deliveryForms.value[0].contracts = [{ contractId: 'fast' }]
|
|
await watchDelivery([{ contractKey: 'fast' }], [{ contractKey: 'slow' }])
|
|
h.deliveryForms.value[0].products = [{ productId: 'new-selection' }]
|
|
slow.resolve({ id: 'slow', products: [] })
|
|
await first
|
|
assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'new-selection' }])
|
|
})
|
|
for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange']) {
|
|
for (const newPlan of [false, true]) {
|
|
test(`late ${newPlan ? 'create' : 'update'} ${mode} cannot emit, arrange or clear a new session busy state`, async () => {
|
|
const write = deferred()
|
|
let started = false
|
|
const h = harness({ newPlan })
|
|
await h.handleOpened()
|
|
h.formData.vehicleId = 'v1'
|
|
const apiMethod = newPlan ? 'createFull' : 'update'
|
|
h.planApi[apiMethod] = async () => {
|
|
started = true
|
|
return write.promise
|
|
}
|
|
const saving = h.handleSubmit(mode)
|
|
while (!started) await Promise.resolve()
|
|
await h.handleOpened()
|
|
h.submitLoading.value = true
|
|
h.events.splice(0)
|
|
write.resolve({ id: 'new-plan' })
|
|
await saving
|
|
assert.equal(h.submitLoading.value, true)
|
|
assert.deepEqual(h.events, [])
|
|
assert.deepEqual(h.writes, [])
|
|
})
|
|
}
|
|
}
|
|
for (const stage of ['detail', 'history', 'confirm', 'arrange']) {
|
|
test(`arrange ${stage} completion after unmount cannot continue or emit`, async () => {
|
|
const pending = deferred()
|
|
let started = false
|
|
const pause = async () => {
|
|
started = true
|
|
return pending.promise
|
|
}
|
|
const h = harness({
|
|
realArrange: true,
|
|
overrides:
|
|
stage === 'confirm'
|
|
? { confirmBeforePlanArrange: pause }
|
|
: stage === 'history'
|
|
? { loadArrangeHistoricalFlags: pause }
|
|
: stage === 'arrange'
|
|
? { submitPlanArrange: pause }
|
|
: {}
|
|
})
|
|
await h.handleOpened()
|
|
h.formData.vehicleId = 'v1'
|
|
if (stage === 'detail') h.planApi.get = pause
|
|
else
|
|
h.planApi.get = async () => ({
|
|
id: 'plan-1',
|
|
transportStatus: stage === 'history' ? 'arranged' : 'pending',
|
|
products: []
|
|
})
|
|
const work = h.performArrangeAfterSave('plan-1')
|
|
while (!started) await Promise.resolve()
|
|
h.invalidatePageLoad()
|
|
h.events.splice(0)
|
|
pending.resolve(stage === 'detail' ? { id: 'plan-1', products: [] } : {})
|
|
await work
|
|
assert.deepEqual(h.events, [])
|
|
assert.deepEqual(h.writes, [])
|
|
})
|
|
}
|
|
for (const [file, name] of [
|
|
['usePlContractToPlan', 'enrichPlanProductLinesAlias'],
|
|
['usePlToxicProductCertCheck', 'enrichPlanProductsHazardFromCrm']
|
|
]) {
|
|
for (const response of [{}, { data: null }, { id: 'other' }]) {
|
|
test(`${name} strict rejects malformed 200 ${JSON.stringify(response)} and preserves snapshot`, async () => {
|
|
const helper = enrichmentHelper(file, name, async () => response)
|
|
const rows = [
|
|
{
|
|
productId: 'p1',
|
|
productName: '历史产品',
|
|
isToxic: 1,
|
|
brandMode: 'required',
|
|
brandId: 'brand-1'
|
|
}
|
|
]
|
|
const before = structuredClone(rows)
|
|
await assert.rejects(helper(rows, true), /产品/)
|
|
assert.deepEqual(rows, before)
|
|
await assert.doesNotReject(helper(structuredClone(rows)))
|
|
})
|
|
}
|
|
}
|
|
for (const response of [
|
|
{},
|
|
null,
|
|
{ id: 'other', products: [] },
|
|
{ id: 'c1' },
|
|
{ id: 'c1', products: {} }
|
|
]) {
|
|
test(`mobile contract malformed 200 ${JSON.stringify(response)} cannot cache an empty list`, async () => {
|
|
const h = harness({ contract: async () => response })
|
|
await assert.rejects(h.fetchContractProductIds('c1'), /合同/)
|
|
assert.deepEqual(h.contractProductIdsCache.value, {})
|
|
assert.deepEqual(h.invalidations, ['c1'])
|
|
assert.equal(h.pageReady.value, false)
|
|
})
|
|
}
|
|
for (const copy of [false, true])
|
|
test(`mobile ${copy ? 'copy' : 'edit'} HTTP 404 error is actionable Chinese`, async () => {
|
|
const error = Object.assign(new Error('Request failed with status code 404'), {
|
|
response: { status: 404 }
|
|
})
|
|
const h = harness({
|
|
copy,
|
|
get: async () => {
|
|
throw error
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
assert.equal(h.pageLoadError.value, copy ? '复制来源不存在或已删除' : '运输计划不存在或已删除')
|
|
})
|
|
test('mobile network error is Chinese while backend business message is preserved', async () => {
|
|
for (const [error, expected] of [
|
|
[new Error('Network Error'), '网络连接失败,请检查网络后重新加载'],
|
|
[
|
|
Object.assign(new Error('Request failed with status code 500'), {
|
|
response: { data: { message: '当前租户无权查看此计划' } }
|
|
}),
|
|
'当前租户无权查看此计划'
|
|
]
|
|
]) {
|
|
const h = harness({
|
|
get: async () => {
|
|
throw error
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
assert.equal(h.pageLoadError.value, expected)
|
|
}
|
|
})
|
|
test('mobile error actions meet the 44px minimum tap target', () => {
|
|
const errorStyle = descriptor.styles[0].content.split('.mp-plan-form-page')[0]
|
|
assert.match(errorStyle, /\.mp-plan-load-error[\s\S]*?\.el-button[\s\S]*?min-height:\s*44px/)
|
|
})
|
|
|
|
test('obsolete delivery contract error does not close the ready fast selection', async () => {
|
|
const slow = deferred()
|
|
const h = harness({
|
|
contract: (id: string) => (id === 'slow' ? slow.promise : Promise.resolve({ id, products: [] }))
|
|
})
|
|
await h.handleOpened()
|
|
const watchDelivery = deliveryWatcher(h)
|
|
h.deliveryForms.value = [{ contracts: [{ contractId: 'slow' }], products: [] }]
|
|
const first = watchDelivery([{ contractKey: 'slow' }], [{ contractKey: 'original' }])
|
|
h.deliveryForms.value[0].contracts = [{ contractId: 'fast' }]
|
|
await watchDelivery([{ contractKey: 'fast' }], [{ contractKey: 'slow' }])
|
|
h.deliveryForms.value[0].products = [{ productId: 'new-selection' }]
|
|
slow.reject(new Error('old contract error'))
|
|
await first
|
|
assert.equal(h.pageReady.value, true)
|
|
assert.equal(h.pageLoadError.value, '')
|
|
assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'new-selection' }])
|
|
})
|
|
test('arrange hazard completion after unmount does not continue to confirmation', async () => {
|
|
const pending = deferred()
|
|
let calls = 0
|
|
let started = false
|
|
let confirmed = false
|
|
const h = harness({
|
|
realArrange: true,
|
|
enrich: async (rows: any) => {
|
|
calls += 1
|
|
if (calls === 1) return rows
|
|
started = true
|
|
return pending.promise
|
|
},
|
|
overrides: {
|
|
confirmBeforePlanArrange: async () => {
|
|
confirmed = true
|
|
}
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
h.formData.vehicleId = 'v1'
|
|
const work = h.performArrangeAfterSave('plan-1')
|
|
while (!started) await Promise.resolve()
|
|
h.invalidatePageLoad()
|
|
pending.resolve([])
|
|
await work
|
|
assert.equal(confirmed, false)
|
|
assert.deepEqual(h.events, [])
|
|
assert.deepEqual(h.writes, [])
|
|
})
|
|
|
|
function loadWatcher(h: ReturnType<typeof harness>) {
|
|
const watcher = ast.statements.find(
|
|
(node) =>
|
|
ts.isExpressionStatement(node) &&
|
|
node.getText(ast).startsWith('watch(') &&
|
|
node.getText(ast).includes('() => loadForm.value.contracts')
|
|
)
|
|
assert.ok(watcher)
|
|
let callback!: (...args: any[]) => Promise<void>
|
|
const bindings = {
|
|
...h,
|
|
pageLoadGeneration: 1,
|
|
loadContractFetchSeq: 0,
|
|
filterProductsByAllowedProductIds,
|
|
syncFromLoadProducts: () => {},
|
|
watch: (_source: any, cb: any) => {
|
|
callback = cb
|
|
}
|
|
}
|
|
new Function(
|
|
...Object.keys(bindings),
|
|
ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
|
|
)(...Object.values(bindings))
|
|
return callback
|
|
}
|
|
for (const rejects of [false, true])
|
|
test(`load contract old ${rejects ? 'error' : 'success'} cannot affect current selection`, async () => {
|
|
const slow = deferred()
|
|
const h = harness({
|
|
contract: (id: string) =>
|
|
id === 'slow'
|
|
? slow.promise
|
|
: Promise.resolve({ id, products: [{ productId: 'fast-product' }] })
|
|
})
|
|
await h.handleOpened()
|
|
const watchLoad = loadWatcher(h)
|
|
h.loadForm.value = { contracts: [{ contractId: 'slow' }], products: [] }
|
|
const first = watchLoad([{ contractId: 'slow' }], [{ contractId: 'original' }])
|
|
h.loadForm.value.contracts = [{ contractId: 'fast' }]
|
|
await watchLoad([{ contractId: 'fast' }], [{ contractId: 'slow' }])
|
|
h.loadForm.value.products = [{ productId: 'fast-product' }]
|
|
if (rejects) slow.reject(new Error('obsolete'))
|
|
else slow.resolve({ id: 'slow', products: [{ productId: 'slow-product' }] })
|
|
await first
|
|
assert.deepEqual(h.loadForm.value.products, [{ productId: 'fast-product' }])
|
|
assert.deepEqual(h.loadContractProductIds.value, ['fast-product'])
|
|
assert.equal(h.pageReady.value, true)
|
|
assert.equal(h.pageLoadError.value, '')
|
|
})
|
|
for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange'])
|
|
test(`desktop ${mode} keeps its save and arrange completion`, async () => {
|
|
const h = harness({ page: false, realArrange: true })
|
|
await h.handleOpened()
|
|
h.formData.vehicleId = 'v1'
|
|
h.invalidatePageLoad()
|
|
await h.handleSubmit(mode)
|
|
assert.equal(h.writes[0][0], 'update')
|
|
assert.equal(
|
|
h.writes.some((row) => row[0] === 'arrange'),
|
|
mode === 'saveAndArrange' || mode === 'rearrange'
|
|
)
|
|
assert.equal(h.events.filter((event) => event[0] === 'success').length, 1)
|
|
assert.equal(h.submitLoading.value, false)
|
|
})
|
|
|
|
// Execute the production watcher through Vue's scheduler, together with the real
|
|
// fetchContractProductIds closure from harness(). Only the contract API is stubbed.
|
|
function mountDeliveryWatcher(h: ReturnType<typeof harness>) {
|
|
const watcher = ast.statements.find(
|
|
(node) =>
|
|
ts.isExpressionStatement(node) &&
|
|
node.getText(ast).startsWith('watch(') &&
|
|
node.getText(ast).includes('contractKey: collectStopContractIds')
|
|
)
|
|
assert.ok(watcher)
|
|
const bindings = { ...h, watch, pageLoadGeneration: 1, deliveryContractFetchSeqByIndex: {} }
|
|
const scope = effectScope()
|
|
scope.run(() =>
|
|
new Function(
|
|
...Object.keys(bindings),
|
|
ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
|
|
)(...Object.values(bindings))
|
|
)
|
|
return () => scope.stop()
|
|
}
|
|
async function flushDeliveryWatchers() {
|
|
for (let tick = 0; tick < 30; tick += 1) await nextTick()
|
|
}
|
|
for (const page of [true, false])
|
|
test(`real Vue delivery watcher ${page ? 'mobile' : 'desktop'} clears both changed segments despite crossed requests`, async () => {
|
|
const slow = deferred()
|
|
const h = harness({
|
|
page,
|
|
contract: async (id: string) => {
|
|
if (id === 'new-0') await slow.promise
|
|
return { id, products: [] }
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
h.deliveryForms.value = [
|
|
{ contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0' }] },
|
|
{ contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1' }] }
|
|
]
|
|
const stop = mountDeliveryWatcher(h)
|
|
try {
|
|
h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
|
|
await nextTick()
|
|
h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
|
|
await nextTick()
|
|
slow.resolve({})
|
|
await flushDeliveryWatchers()
|
|
assert.deepEqual(
|
|
h.deliveryForms.value.map((row: any) => row.products),
|
|
[[], []]
|
|
)
|
|
assert.equal(h.pageLoadError.value, '')
|
|
} finally {
|
|
stop()
|
|
}
|
|
})
|
|
test('real Vue watcher does not let an earlier batched callback supersede a later segment selection', async () => {
|
|
const slow = deferred()
|
|
const fast = deferred()
|
|
const requests: string[] = []
|
|
const h = harness({
|
|
contract: async (id: string) => {
|
|
requests.push(id)
|
|
if (id === 'new-0') await slow.promise
|
|
if (id === 'latest-1') await fast.promise
|
|
return { id, products: [] }
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
h.deliveryForms.value = [
|
|
{ contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0' }] },
|
|
{ contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1' }] }
|
|
]
|
|
const stop = mountDeliveryWatcher(h)
|
|
try {
|
|
h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
|
|
h.deliveryForms.value[1].contracts = [{ contractId: 'intermediate-1' }]
|
|
await nextTick()
|
|
h.deliveryForms.value[1].contracts = [{ contractId: 'latest-1' }]
|
|
await nextTick()
|
|
slow.resolve({})
|
|
await flushDeliveryWatchers()
|
|
fast.resolve({})
|
|
await flushDeliveryWatchers()
|
|
assert.deepEqual(
|
|
h.deliveryForms.value.map((row: any) => row.products),
|
|
[[], []]
|
|
)
|
|
assert.equal(requests.includes('intermediate-1'), false)
|
|
h.deliveryForms.value[1].products = [{ productId: 'latest-product-1' }]
|
|
await flushDeliveryWatchers()
|
|
assert.deepEqual(h.deliveryForms.value[1].products, [{ productId: 'latest-product-1' }])
|
|
} finally {
|
|
stop()
|
|
}
|
|
})
|
|
test('real Vue watcher preserves newly selected products while another segment finishes later', async () => {
|
|
const slow = deferred()
|
|
const h = harness({
|
|
contract: async (id: string) => {
|
|
if (id === 'new-0') await slow.promise
|
|
return { id, products: [] }
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
h.deliveryForms.value = [
|
|
{ contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0' }] },
|
|
{ contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1' }] }
|
|
]
|
|
const stop = mountDeliveryWatcher(h)
|
|
try {
|
|
h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
|
|
await nextTick()
|
|
h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
|
|
await flushDeliveryWatchers()
|
|
h.deliveryForms.value[1].products = [{ productId: 'new-selection' }]
|
|
slow.resolve({})
|
|
await flushDeliveryWatchers()
|
|
assert.deepEqual(h.deliveryForms.value[0].products, [])
|
|
assert.deepEqual(h.deliveryForms.value[1].products, [{ productId: 'new-selection' }])
|
|
} finally {
|
|
stop()
|
|
}
|
|
})
|
|
|
|
for (const rejects of [false, true])
|
|
test(`real Vue batched delivery changes still process later segments after obsolete first ${rejects ? 'error' : 'success'}`, async () => {
|
|
const slow = deferred()
|
|
const requests: string[] = []
|
|
const h = harness({
|
|
contract: async (id: string) => {
|
|
requests.push(id)
|
|
if (id === 'slow-0') await slow.promise
|
|
return { id, products: [] }
|
|
}
|
|
})
|
|
await h.handleOpened()
|
|
h.deliveryForms.value = [
|
|
{ contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0' }] },
|
|
{ contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1' }] }
|
|
]
|
|
const stop = mountDeliveryWatcher(h)
|
|
try {
|
|
h.deliveryForms.value[0].contracts = [{ contractId: 'slow-0' }]
|
|
h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
|
|
await nextTick()
|
|
h.deliveryForms.value[0].contracts = [{ contractId: 'latest-0' }]
|
|
await flushDeliveryWatchers()
|
|
if (rejects) slow.reject(new Error('obsolete first segment'))
|
|
else slow.resolve({})
|
|
await flushDeliveryWatchers()
|
|
assert.ok(
|
|
requests.includes('new-1'),
|
|
'the batched later segment must still request its contract'
|
|
)
|
|
assert.deepEqual(
|
|
h.deliveryForms.value.map((row: any) => row.products),
|
|
[[], []]
|
|
)
|
|
assert.equal(h.pageReady.value, true)
|
|
assert.equal(h.pageLoadError.value, '')
|
|
} finally {
|
|
stop()
|
|
}
|
|
})
|