| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173 |
- 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'
- 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> = {
- 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,
- 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<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(() => {})
- 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<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 = {
- 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' }], 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 = {
- 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<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, 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' }] },
- { 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()
- }
- })
- 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'}],notes:'A'},
- {customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'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'}],[{productId:'a'}]])
- slow.resolve({}); await flushDeliveryWatchers()
- assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'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'}]}
- 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,/计划不存在/)
- })
|