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 { preserveMobileCopyAssociations, assertMobilePersistenceCapability } from '../src/views/m/pl/plan/mobilePlanPersistence' import { mobileStopIdentity } from '../src/views/m/pl/plan/mobileStopIdentity' import * as brand from '../src/utils/crm-product-brand' import { collectStopContractIds, filterProductsByAllowedProductIds } from '../src/utils/pl-plan-contract-product' import { describeUntaggedExternalStopProducts, retainStopProductsForContracts } from '../src/utils/pl-plan-stop-form' 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() { let resolve!: (value: T) => void let reject!: (reason: any) => void const promise = new Promise((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 } = {} ) { const writes: any[] = [] const events: any[] = [] const invalidations: string[] = [] const noop = () => {} const bindings: Record = { mobileStopIdentity, preserveMobileCopyAssociations, buildMobileProductLinks: () => ({}), assertMobilePersistenceCapability: () => {}, 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: { mobileProductCapability: async () => ({ mobileProductMetaVersion: 1 }), 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, describeUntaggedExternalStopProducts, retainStopProductsForContracts, 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 () => {}, mobileMetadata: { ready: () => true }, 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 () => {} }, createArrangePresenter: () => async () => {}, planHasAnyDibangSelected: () => false, loadArrangeHistoricalFlags: async () => ({}), confirmBeforePlanArrange: async () => {}, buildArrangePayloadFromPlanForm: () => ({}), submitPlanArrange: async (id: string) => { writes.push(['arrange', id]) }, getUnitLabel: () => '', ...options.overrides } const names = [ 'mobileSavedPlanId', 'mobileCreateUncertain', 'pageReady', 'pageLoadError', 'pageLoadGeneration', 'pageDisposed', 'isPageLoadCurrent', 'assertPageLoadCurrent', 'failPageLoad', 'invalidatePageLoad', 'validateLoadedPagePlan', 'handleOpened', 'ensureMobilePersistenceCapability', '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) { 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, and formal save waits for a contract line', 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, []) h.loadForm.value.products[0].contractId = 'c1' h.loadForm.value.products[0].contractProductId = 'line-1' 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(() => {}) while (count < 1) await Promise.resolve() const second = h.handleOpened() while (count < 2) 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() while (count < 1) await Promise.resolve() 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) { 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 const bindings = { mobileStopIdentity, ...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', contractId: 'c1' }], 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) { 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 const bindings = { mobileStopIdentity, ...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) { 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, mobileStopIdentity, 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', contractId: 'old-0' }] }, { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-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', contractId: 'old-0' }] }, { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-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', contractId: 'old-0' }] }, { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-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', contractId: 'old-0' }] }, { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-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() } }) test('mobile delivery watcher preserves segment products and notes during reorder and clears only changed contract target', async () => { const slow=deferred() const h=harness({contract:async(id:string)=>{if(id==='new-a')await slow.promise;return {id,products:[]}}}) await h.handleOpened() h.deliveryForms.value=[ {customerId:'same',contracts:[{contractId:'old-a'}],products:[{productId:'a',contractId:'old-a'}],notes:'A'}, {customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'b',contractId:'old-b'}],notes:'B'} ] const stop=mountDeliveryWatcher(h) try { h.deliveryForms.value[0].contracts=[{contractId:'new-a'}] await nextTick() h.deliveryForms.value=[{...h.deliveryForms.value[1],seq:1},{...h.deliveryForms.value[0],seq:2}] await flushDeliveryWatchers() assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'b',contractId:'old-b'}],[{productId:'a',contractId:'old-a'}]]) slow.resolve({}); await flushDeliveryWatchers() assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'b',contractId:'old-b'}],[]]) assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.notes),['B','A']) } finally {stop()} }) test('mobile removed delivery response cannot clear surviving same-customer segment', async () => { const slow=deferred(), h=harness({contract:async(id:string)=>{if(id==='new-a')await slow.promise;return {id,products:[]}}}) await h.handleOpened() h.deliveryForms.value=[{customerId:'same',contracts:[{contractId:'old-a'}],products:[{productId:'a'}]},{customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'b'}]}] const stop=mountDeliveryWatcher(h) try { h.deliveryForms.value[0].contracts=[{contractId:'new-a'}];await nextTick() h.deliveryForms.value=[{...h.deliveryForms.value[1],seq:1}];await flushDeliveryWatchers() slow.resolve({});await flushDeliveryWatchers() assert.deepEqual(h.deliveryForms.value[0].products,[{productId:'b'}]) } finally {stop()} }) for(const removed of [false,true]) test(`mobile load contract ${removed?'removal':'replacement with same product'} clears only its products`,async()=>{ const h=harness({contract:async(id:string)=>({id,products:[{productId:'p'}]})}) await h.handleOpened() h.loadForm.value={contracts:removed?[]:[{contractId:'new'}],products:[{productId:'p',contractProductId:'old-line',contractId:'old'}]} const run=loadWatcher(h) await run(h.loadForm.value.contracts,[{contractId:'old'}]) assert.deepEqual(h.loadForm.value.products,[]) }) for (const newPlan of [false, true]) test(`old server without capability blocks ${newPlan ? 'new' : 'edit'} page readiness and all writes`, async () => { const h = harness({ newPlan, overrides: { assertMobilePersistenceCapability, planApi: { mobileProductCapability: async () => ({}) } } }) await h.handleOpened() assert.equal(h.pageReady.value, false) assert.match(h.pageLoadError.value, /服务.*更新/) await h.handleSubmit('draft') assert.deepEqual(h.writes, []) }) test('successful capability handshake still rejects an old detail response without the explicit marker', async () => { const h = harness({ overrides: { assertMobilePersistenceCapability } }) await h.handleOpened() assert.equal(h.pageReady.value, false) assert.match(h.pageLoadError.value, /服务.*更新/) }) test('actual copy mapper preserves same-packaging second-load and shared delivery association while clearing source ids', async () => { const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence') const product = (id: string, planProductId: string) => ({ id, planProductId, productId: 'p', productNo: 'P', productName: '标准名', packageSpec: '20kg/袋', model: 'M', unit: '吨' }) const sourcePlan = { id: 'plan-1', load: { products: [product('l1', 'pp1'), product('l2', 'pp2')], contracts: [] }, deliveries: [{ id: 'd1', products: [product('dl1', 'pp2')], contracts: [] }, { id: 'd2', products: [product('dl2', 'pp2')], contracts: [] }], products: [] } const h = harness({ copy: true, get: async () => sourcePlan }) await h.handleOpened() assert.equal(h.pageReady.value, true) const key = mobileLoadProductKey(h.loadForm.value.products[1]) assert.deepEqual(h.deliveryForms.value.map((row: any) => row.products[0].mobileLoadProductKey), [key, key]) assert.ok(h.loadForm.value.products.every((row: any) => !row.id && !row.planProductId)) assert.ok(h.deliveryForms.value.every((row: any) => !row.id && !row.products[0].id && !row.products[0].planProductId)) }) test('capability endpoint 404 explains service upgrade rather than a missing plan', async () => { const h = harness({newPlan: true, overrides: {planApi: {mobileProductCapability: async () => {throw Object.assign(new Error('Request failed with status code 404'), {response:{status:404}})}}}}) await h.handleOpened() assert.equal(h.pageReady.value,false) assert.match(h.pageLoadError.value,/服务.*更新/) assert.doesNotMatch(h.pageLoadError.value,/计划不存在/) })