| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050 |
- import assert from 'node:assert/strict'
- import test from 'node:test'
- import { readFileSync } from 'node:fs'
- import { parse } from '@vue/compiler-sfc'
- import ts from 'typescript'
- import * as vue from 'vue'
- function deferred() {
- let resolve!: (value?: any) => void
- let reject!: (error: any) => void
- const promise = new Promise<any>((a, b) => {
- resolve = a
- reject = b
- })
- return { promise, resolve, reject }
- }
- const flush = async () => {
- for (let i = 0; i < 20; i++) await Promise.resolve()
- }
- function harness(page: string, overrides: Record<string, any> = {}, initialProps: any = {}) {
- const content = parse(
- readFileSync(new URL(`../src/views/m/pl/${page}.vue`, import.meta.url), 'utf8')
- ).descriptor.scriptSetup!.content
- const ast = ts.createSourceFile('page.ts', content, ts.ScriptTarget.Latest, true)
- const route = vue.reactive({
- params: { id: 'A' },
- query: {} as Record<string, string>,
- fullPath: '/A',
- path: '/A'
- })
- const props = vue.reactive({ modelValue: true, planId: 'A', permitted: true, ...initialProps })
- const messages: any[] = []
- const unmounts: Array<() => void> = []
- const writes: any[] = [],
- pushes: any[] = [],
- calls: string[] = []
- const bindings: Record<string, any> = Object.fromEntries(
- Object.entries(vue).filter(
- ([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default' && key !== 'version'
- )
- )
- for (const s of ast.statements) {
- if (!ts.isImportDeclaration(s)) continue
- const clause = s.importClause
- if (clause?.name) bindings[clause.name.text] = () => undefined
- if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
- for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
- }
- const read = async (arg: any) => {
- const id = typeof arg === 'string' ? arg : arg.id
- calls.push(id)
- return {
- data: {
- id,
- status: 'draft',
- products: [{ id: 'p', productId: 'p', isToxic: 1 }],
- productList: []
- }
- }
- }
- const planApi = {
- get: read,
- getArrangeInfo: async () => ({ data: {} }),
- waitNotice: async (id: string) => {
- writes.push(id)
- return { data: { id, products: [] } }
- },
- cancelWaitNotice: async (id: string) => {
- writes.push(id)
- return { data: { id, products: [] } }
- },
- cancel: async (...args: any[]) => {
- writes.push(args)
- return { data: { id: args[0], products: [] } }
- },
- setHazardCertReady: async (...args: any[]) => writes.push(args),
- updateProductCerts: async (...args: any[]) => writes.push(args),
- ...overrides.planApi
- }
- Object.assign(bindings, {
- useRoute: () => route,
- useRouter: () => ({
- push: (v: any) => pushes.push(v),
- replace: async (v: any) => {
- if (typeof v === 'object') route.query = v.query
- else pushes.push(v)
- }
- }),
- useUserStore: () => ({ accessToken: 'session', info: { id: 'user' } }),
- useDictStore: () => ({ getLabel: () => '', getTagType: () => '' }),
- onMounted() {},
- onUnmounted: (fn: any) => unmounts.push(fn),
- onBeforeUnmount: (fn: any) => unmounts.push(fn),
- loadMobileDetailReads: async () => ({ errors: [] }),
- resolvePlanStopsForList: (plan: any) => ({
- load: plan.load || {},
- deliveries: plan.deliveries || []
- }),
- mapPlanProductToFormRow: (product: any) => product,
- mapLoadFromApi: (load: any) => load,
- mapDeliveriesFromApi: (deliveries: any[]) => deliveries,
- resolveStopProductsForView: (products: any[]) => products || [],
- resolveLoadViewAddress: (_plan: any, load: any) => load.address || '',
- resolveDeliveryViewAddress: (_plan: any, stop: any) => stop.address || '',
- getContract: read,
- getQuote: read,
- fetchProductDetail: read,
- listContractApproval: async () => ({ data: [] }),
- ElMessage: Object.fromEntries(
- ['warning', 'error', 'success', 'info'].map((k) => [
- k,
- (...args: any[]) => messages.push([k, ...args])
- ])
- ),
- fetchGetMenuCodes: async () => ({ all: true }),
- canEditPlan: () => true,
- canArrangeTransport: () => true,
- canWaitNotice: () => true,
- canCancelWaitNotice: () => true,
- canVoidPlan: () => true,
- canEdit: (p: any) => !!p,
- canSave: (a: boolean, u: boolean, s: boolean, n: number) => a && !u && !s && n > 0,
- certPayload: (p: any) => JSON.parse(JSON.stringify(p)),
- isHazardProduct: (p: any) => !!p.isToxic,
- enrichPlanProductsHazardFromCrm: async (p: any) => p,
- isHazardCertReady: () => false,
- planHasHazardProducts: (p: any) => !!p?.length,
- buildPlanCard: () => ({}),
- splitMpPlanDetailActions: () => ({ primary: [], more: [] }),
- usePlPlanArrangeActions: (options: any) => arrangeHarness(planApi, options),
- showCrmConfirmDialog: async () => {},
- defineExpose() {},
- defineProps: () => props,
- defineEmits:
- () =>
- (...v: any[]) =>
- pushes.push(v),
- document: { querySelector: () => null },
- window: { removeEventListener() {}, scrollTo() {} },
- ...overrides,
- planApi
- })
- const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
- const names = statements.flatMap((s) =>
- ts.isFunctionDeclaration(s)
- ? [s.name!.text]
- : ts.isVariableStatement(s)
- ? s.declarationList.declarations.flatMap((d) =>
- ts.isObjectBindingPattern(d.name)
- ? d.name.elements.map((e) => e.name.getText(ast))
- : [d.name.getText(ast)]
- )
- : []
- )
- const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
- target: ts.ScriptTarget.ESNext
- })
- const scope = vue.effectScope()
- const api: any = scope.run(() =>
- new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
- ...Object.values(bindings)
- )
- )
- return {
- ...api,
- props: bindings.defineProps(),
- messages,
- planApi,
- route,
- calls,
- writes,
- pushes,
- ready: api.onShellReady || api.onReady,
- unmount: () => {
- unmounts.forEach((fn) => fn())
- scope.stop()
- }
- }
- }
- function arrangeHarness(planApi: any, options: any = undefined, overrides: any = {}) {
- const text = readFileSync(
- new URL('../src/composables/usePlPlanArrangeActions.ts', import.meta.url),
- 'utf8'
- )
- const ast = ts.createSourceFile('arrange.ts', text, ts.ScriptTarget.Latest, true)
- const code = ts.transpile(
- ast.statements
- .filter((s) => !ts.isImportDeclaration(s))
- .map((s) => s.getText(ast))
- .join('\n')
- .replace(/export /g, ''),
- { target: ts.ScriptTarget.ESNext }
- )
- const env = {
- ...Object.fromEntries(
- Object.entries(vue).filter(([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'version')
- ),
- planApi,
- pickBxData: (r: any) => r.data ?? r,
- useUserStore: () => ({ accessToken: 'session', info: { id: 'user' } }),
- useDictStore: () => ({ getLabel: () => '' }),
- ElMessage: { success() {}, warning() {}, error() {} },
- ...overrides
- }
- delete (env as any).default
- return new Function(...Object.keys(env), `${code}; return usePlPlanArrangeActions`)(
- ...Object.values(env)
- )(options)
- }
- test('transport: ready gate, idempotence and sync reset', async () => {
- const h = harness('plan/detail')
- h.route.params.id = 'B'
- await flush()
- assert.deepEqual(h.calls, [])
- h.onShellReady()
- await flush()
- h.onShellReady()
- await flush()
- assert.deepEqual(h.calls, ['B'])
- h.moreOpen.value = h.voidOpen.value = h.vehicleOpen.value = true
- h.route.params.id = 'C'
- assert.equal(h.plan.value, null)
- assert.equal(h.moreOpen.value, false)
- assert.equal(h.voidOpen.value, false)
- assert.equal(h.vehicleOpen.value, false)
- await flush()
- assert.deepEqual(h.calls, ['B', 'C'])
- h.route.query.tab = 'info'
- await flush()
- assert.deepEqual(h.calls, ['B', 'C'])
- h.unmount()
- })
- for (const result of [null, {}, { id: 'WRONG' }])
- test(`transport invalid success ${JSON.stringify(result)}`, async () => {
- const h = harness('plan/detail', { planApi: { get: async () => ({ data: result }) } })
- h.onShellReady()
- await flush()
- assert.equal(h.plan.value, null)
- assert.ok(h.errorText.value)
- h.unmount()
- })
- for (const kind of ['wait', 'cancel-wait', 'void'])
- test(`transport ${kind} delayed confirmation`, async () => {
- const confirm = deferred()
- const h = harness('plan/detail', { showCrmConfirmDialog: () => confirm.promise })
- h.onShellReady()
- await flush()
- const pending = kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind)
- confirm.resolve()
- h.route.params.id = 'B'
- await pending
- await flush()
- assert.deepEqual(h.writes, [])
- h.unmount()
- })
- test('arrange opt-in old internal info cannot publish after reset', async () => {
- const old = deferred()
- const a = arrangeHarness({ getArrangeInfo: () => old.promise }, { isolateRecord: true })
- const pending = a.loadArrangeActions('A', { id: 'A', transportStatus: 'arranged' })
- a.resetArrangeActions()
- old.resolve({ data: { driverUrl: 'OLD', siteRecords: [{ siteUrl: 'OLD' }] } })
- await pending
- assert.equal(a.driverUrl.value, '')
- assert.deepEqual(a.siteRecords.value, [])
- })
- test('cert parent delayed confirmation invalidated synchronously', async () => {
- const confirm = deferred()
- const h = harness('plan-cert/detail', { showCrmConfirmDialog: () => confirm.promise })
- await h.init()
- const pending = h.setReady({ id: 'A' }, true)
- await flush()
- confirm.resolve()
- h.route.params.id = 'B'
- await pending
- assert.deepEqual(h.writes, [])
- h.unmount()
- })
- test('cert upload old successful save cannot close or notify new record', async () => {
- const saved = deferred()
- const h = harness('plan-cert/CertUpload', {
- planApi: { updateProductCerts: () => saved.promise }
- })
- await flush()
- const pending = h.save()
- h.props.planId = 'B'
- await flush()
- saved.resolve()
- await pending
- assert.deepEqual(h.pushes, [])
- assert.deepEqual(h.messages, [])
- h.unmount()
- })
- function detailLoader(read: (id: string) => Promise<any>, useInitialPlan = false) {
- return () => {
- const state: any = Object.fromEntries(
- [
- 'plan',
- 'products',
- 'transport',
- 'freightSettlement',
- 'vehicleBundle',
- 'dibangRows',
- 'ioOrder'
- ].map((k) => [k, vue.ref(k === 'products' || k === 'dibangRows' ? [] : null)])
- )
- state.loading = vue.ref(false)
- state.resetDetail = () => {
- state.plan.value = null
- }
- state.loadDetail = async (id: string, options?: any) => {
- state.loading.value = true
- try {
- state.plan.value =
- useInitialPlan && options?.initialPlan ? options.initialPlan : await read(id)
- } finally {
- state.loading.value = false
- }
- }
- return state
- }
- }
- for (const reject of [false, true])
- test(`cert detail unmount guards old ${reject ? 'error' : 'success'} and contract`, async () => {
- const slow = deferred(),
- contract = deferred()
- const h = harness('plan-cert/CertDetail', {
- usePlPlanViewDetail: detailLoader(() => slow.promise),
- getContract: () => contract.promise
- })
- await flush()
- const pending = h.openContract('C')
- h.unmount()
- if (reject) slow.reject(new Error('old'))
- else slow.resolve({ id: 'A' })
- contract.resolve({ data: { id: 'C' } })
- await pending
- await flush()
- assert.equal(h.contractOpen.value, false)
- assert.equal(h.error.value, '')
- assert.equal(h.plan.value, null)
- })
- for (const reject of [false, true])
- for (const transition of ['B', 'ABA', 'retry', 'unmount'])
- test(`transport ${transition}: old ${reject ? 'error/finally' : 'success'} cannot publish`, async () => {
- const old = deferred(),
- fresh = deferred()
- let n = 0
- const h = harness('plan/detail', {
- planApi: { get: () => (++n === 1 ? old.promise : fresh.promise) }
- })
- h.onShellReady()
- await flush()
- if (transition === 'unmount') h.unmount()
- else if (transition === 'retry') void h.loadDetail()
- else {
- h.route.params.id = 'B'
- if (transition === 'ABA') h.route.params.id = 'A'
- }
- if (reject) old.reject(new Error('old'))
- else old.resolve({ data: { id: 'A' } })
- await flush()
- assert.equal(h.plan.value, null)
- assert.equal(h.errorText.value, '')
- if (transition !== 'unmount') {
- assert.equal(h.loading.value, true)
- fresh.resolve({ data: { id: h.route.params.id } })
- await flush()
- assert.equal(h.plan.value.id, h.route.params.id)
- assert.equal(h.loading.value, false)
- h.unmount()
- }
- })
- for (const kind of ['wait', 'cancel-wait', 'void'])
- for (const reject of [false, true])
- test(`transport ${kind}: old issued write ${reject ? 'rejects' : 'resolves'} after switching`, async () => {
- const old = deferred()
- const apiName =
- kind === 'wait' ? 'waitNotice' : kind === 'void' ? 'cancel' : 'cancelWaitNotice'
- const h = harness('plan/detail', { planApi: { [apiName]: () => old.promise } })
- h.onShellReady()
- await flush()
- const pending = kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind)
- await flush()
- h.route.params.id = 'B'
- await flush()
- h.acting.value = true
- h.voidOpen.value = true
- if (reject) old.reject(new Error('old'))
- else old.resolve({ data: { id: 'A' } })
- await pending
- assert.equal(h.plan.value.id, 'B')
- assert.equal(h.acting.value, true)
- assert.equal(h.voidOpen.value, true)
- assert.deepEqual(h.messages, [])
- assert.deepEqual(h.calls, ['A', 'B'])
- h.unmount()
- })
- for (const kind of ['wait', 'cancel-wait', 'void'])
- test(`transport ${kind}: cancelled confirmation is handled`, async () => {
- const h = harness('plan/detail', {
- showCrmConfirmDialog: async () => {
- throw new Error('cancel')
- }
- })
- h.onShellReady()
- await flush()
- await (kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind))
- assert.deepEqual(h.writes, [])
- assert.equal(h.acting.value, false)
- assert.deepEqual(h.messages, [])
- h.unmount()
- })
- test('transport reason is captured before confirmation; unrelated query does not cancel', async () => {
- const confirm = deferred(),
- h = harness('plan/detail', { showCrmConfirmDialog: () => confirm.promise })
- h.onShellReady()
- await flush()
- h.voidReason.value = 'original'
- const pending = h.confirmVoidPlan()
- h.voidReason.value = 'changed'
- h.route.query.tab = 'x'
- confirm.resolve()
- await pending
- assert.deepEqual(h.writes, [['A', { cancelReason: 'original' }]])
- h.unmount()
- })
- test('transport entry rejects missing loading busy and denied actions', async () => {
- const h = harness('plan/detail', {
- canWaitNotice: () => false,
- canCancelWaitNotice: () => false,
- canVoidPlan: () => false
- })
- await h.runAction('wait')
- h.onShellReady()
- await flush()
- for (const kind of ['wait', 'cancel-wait', 'void']) await h.runAction(kind)
- await h.confirmVoidPlan()
- assert.deepEqual(h.writes, [])
- h.unmount()
- })
- test('arrange opt-in ABA cancels old get and reuses existing plan', async () => {
- const old = deferred()
- let reads = 0
- const a = arrangeHarness(
- {
- get: () => {
- reads++
- return old.promise
- },
- getArrangeInfo: async (id: string) => ({ data: { driverUrl: id } })
- },
- { isolateRecord: true }
- )
- const pending = a.loadArrangeActions('A')
- await a.loadArrangeActions('B', { id: 'B', transportStatus: 'arranged' })
- await a.loadArrangeActions('A', { id: 'A', transportStatus: 'arranged' })
- old.resolve({ data: { id: 'A', transportStatus: 'pending' } })
- await pending
- assert.equal(a.driverUrl.value, 'A')
- assert.equal(a.plan.value.transportStatus, 'arranged')
- assert.equal(reads, 1)
- })
- test('arrange default PC retains legacy publication and lookup behavior', async () => {
- const old = deferred()
- let reads = 0
- const a = arrangeHarness({
- get: async () => {
- reads++
- return { data: { id: 'A', transportStatus: 'arranged' } }
- },
- getArrangeInfo: () => old.promise
- })
- const pending = a.loadArrangeActions('A')
- await flush()
- a.resetArrangeActions()
- old.resolve({ data: { driverUrl: 'legacy' } })
- await pending
- assert.equal(a.driverUrl.value, 'legacy')
- assert.equal(reads, 1)
- })
- test('arrange QR nextTick cannot copy newly bound dialog after reset', async () => {
- let copies = 0
- const a = arrangeHarness({}, { isolateRecord: true })
- const pending = a.copyPoundSlipQrcodeForRecord({ dbInventoryId: 'A' })
- a.resetArrangeActions()
- a.qrcodeDialogRef.value = {
- copyQrcodeImage: () => {
- copies++
- }
- }
- await pending
- assert.equal(copies, 0)
- assert.equal(a.qrcodeVisible.value, false)
- })
- for (const stage of ['get', 'confirm', 'write'])
- for (const reject of [false, true])
- test(`cert ready ${stage}: switched/ABA late ${reject ? 'error' : 'success'} ignored`, async () => {
- const old = deferred(),
- writes: any[] = []
- const h = harness('plan-cert/detail', {
- showCrmConfirmDialog: stage === 'confirm' ? () => old.promise : async () => {},
- planApi: {
- ...(stage === 'get' ? { get: () => old.promise } : {}),
- setHazardCertReady: (...args: any[]) => {
- writes.push(args)
- return stage === 'write' ? old.promise : Promise.resolve()
- }
- }
- })
- await h.init()
- const pending = h.setReady({ id: 'A' }, true)
- await flush()
- h.route.params.id = 'B'
- h.route.params.id = 'A'
- h.acting.value = true
- if (reject) old.reject(new Error('old'))
- else old.resolve({ data: { id: 'A', products: [{ isToxic: 1 }] } })
- await pending
- assert.equal(writes.length, stage === 'write' ? 1 : 0)
- assert.deepEqual(h.messages, [])
- assert.equal(h.acting.value, true)
- h.unmount()
- })
- for (const bad of [null, {}, { id: 'B', products: [{}] }])
- test(`cert ready invalid read ${JSON.stringify(bad)}`, async () => {
- const h = harness('plan-cert/detail', { planApi: { get: async () => ({ data: bad }) } })
- await h.init()
- await h.setReady({ id: 'A' }, true)
- assert.deepEqual(h.writes, [])
- assert.equal(h.acting.value, false)
- h.unmount()
- })
- test('cert parent ready idempotence, mismatched event, saved identity and query gate', async () => {
- let permissionReads = 0,
- reloads = 0,
- opens = 0
- const h = harness('plan-cert/detail', {
- fetchGetMenuCodes: async () => {
- permissionReads++
- return { all: true }
- }
- })
- h.detailRef.value = {
- reload: () => {
- reloads++
- },
- openContract: () => {
- opens++
- },
- invalidateRecord() {}
- }
- h.route.query.contractId = 'contract'
- await flush()
- assert.equal(opens, 0)
- await h.init()
- await h.init()
- assert.equal(permissionReads, 1)
- await h.setReady({ id: 'OTHER' }, true)
- assert.deepEqual(h.calls, [])
- await h.saved('OTHER')
- assert.equal(reloads, 0)
- await h.saved('A')
- assert.equal(reloads, 1)
- h.unmount()
- })
- for (const reject of [false, true])
- test(`cert permissions retry supersedes old ${reject ? 'failure' : 'success'}`, async () => {
- const old = deferred()
- let n = 0
- const h = harness('plan-cert/detail', {
- fetchGetMenuCodes: () => (++n === 1 ? old.promise : Promise.resolve({ all: false }))
- })
- const pending = h.init()
- await h.loadPermissions()
- if (reject) old.reject(new Error('old'))
- else old.resolve({ all: true })
- await pending
- assert.equal(h.canReady.value, false)
- assert.equal(h.permissionError.value, false)
- assert.equal(h.permissionsLoaded.value, true)
- h.unmount()
- })
- for (const transition of ['B', 'ABA', 'close-reopen', 'unmount', 'invalidate', 'permission'])
- for (const reject of [false, true])
- test(`upload ${transition}: stale save ${reject ? 'error' : 'success'}`, async () => {
- const old = deferred()
- const h = harness('plan-cert/CertUpload', {
- planApi: { updateProductCerts: () => old.promise }
- })
- await flush()
- const pending = h.save()
- if (transition === 'B' || transition === 'ABA') {
- h.props.planId = 'B'
- if (transition === 'ABA') h.props.planId = 'A'
- }
- if (transition === 'close-reopen') {
- h.props.modelValue = false
- h.props.modelValue = true
- }
- if (transition === 'unmount') h.unmount()
- if (transition === 'invalidate') h.invalidateRecord()
- if (transition === 'permission') h.props.permitted = false
- await flush()
- h.saving.value = true
- if (reject) old.reject(new Error('old'))
- else old.resolve()
- await pending
- assert.deepEqual(h.pushes, [])
- assert.deepEqual(h.messages, [])
- assert.equal(h.error.value, '')
- assert.equal(h.saving.value, true)
- if (transition !== 'unmount') h.unmount()
- })
- for (const invalid of [{ id: 'B', products: [] }, { id: 'A' }, null])
- test(`upload invalid metadata response ${JSON.stringify(invalid)} blocks saving`, async () => {
- const h = harness('plan-cert/CertUpload', { planApi: { get: async () => ({ data: invalid }) } })
- await flush()
- await h.save()
- assert.ok(h.error.value)
- assert.equal(h.allowed.value, false)
- assert.deepEqual(h.writes, [])
- h.unmount()
- })
- test('upload strict enrichment failure is explicit, retriable and blocks write', async () => {
- let strictArg: any
- const h = harness('plan-cert/CertUpload', {
- enrichPlanProductsHazardFromCrm: async (_: any, strict: any) => {
- strictArg = strict
- throw new Error('metadata')
- }
- })
- await flush()
- await h.save()
- assert.equal(strictArg, true)
- assert.ok(h.error.value)
- assert.deepEqual(h.writes, [])
- h.unmount()
- })
- test('upload keeps edits when same ID/visibility and unrelated query change; payload is captured', async () => {
- const old = deferred(),
- writes: any[] = []
- const h = harness('plan-cert/CertUpload', {
- planApi: {
- updateProductCerts: (...args: any[]) => {
- writes.push(args)
- return old.promise
- }
- }
- })
- await flush()
- h.products.value[0].purchaseCertUrl = 'original'
- h.props.planId = 'A'
- h.props.modelValue = true
- h.route.query.tab = 'info'
- await flush()
- assert.equal(h.products.value[0].purchaseCertUrl, 'original')
- assert.equal(h.calls.length, 1)
- const pending = h.save()
- h.products.value[0].purchaseCertUrl = 'changed'
- old.resolve()
- await pending
- assert.equal(writes[0][0], 'A')
- assert.equal(writes[0][1][0].purchaseCertUrl, 'original')
- assert.deepEqual(h.pushes, [
- ['update:modelValue', false],
- ['success', 'A']
- ])
- h.unmount()
- })
- for (const reject of [false, true])
- test(`upload stale metadata ${reject ? 'error' : 'success'} cannot clear new loading`, async () => {
- const old = deferred(),
- fresh = deferred()
- let n = 0
- const h = harness('plan-cert/CertUpload', {
- planApi: { get: () => (++n === 1 ? old.promise : fresh.promise) }
- })
- h.props.planId = 'B'
- if (reject) old.reject(new Error('old'))
- else old.resolve({ data: { id: 'A', products: [] } })
- await flush()
- assert.equal(h.loading.value, true)
- assert.equal(h.plan.value, null)
- assert.equal(h.error.value, '')
- await h.save()
- assert.deepEqual(h.writes, [])
- fresh.resolve({ data: { id: 'B', products: [] } })
- await flush()
- assert.equal(h.plan.value.id, 'B')
- h.unmount()
- })
- function recordChildKey(parent: any, component: string) {
- const template = parse(
- readFileSync(new URL('../src/views/m/pl/plan-cert/detail.vue', import.meta.url), 'utf8')
- ).descriptor.template!.content
- const key = template.match(new RegExp(`<${component}\\b[^>]*:key="([^"]+)"`))?.[1]
- assert.ok(key, `${component} must have a record-instance key`)
- return new Function('state', `with (state) { return (${key}) }`)(vue.proxyRefs(parent))
- }
- for (const change of ['record', 'ABA', 'query'])
- test(`cert upload real parent renderer ${change} before child props flush`, async () => {
- const saved = deferred()
- let child: any
- const Child = vue.defineComponent({
- props: ['modelValue', 'planId', 'permitted'],
- emits: ['update:modelValue', 'success'],
- setup(props, context) {
- child = harness('plan-cert/CertUpload', {
- defineProps: () => props,
- defineEmits: () => context.emit,
- defineExpose: context.expose,
- onBeforeUnmount: vue.onBeforeUnmount,
- planApi: { updateProductCerts: () => saved.promise }
- })
- return () => vue.h('upload')
- }
- })
- const renderer = vue.createRenderer({
- insert(el: any, parent: any) {
- el.parent = parent
- },
- remove() {},
- createElement: () => ({}),
- createText: () => ({}),
- createComment: () => ({}),
- setText() {},
- setElementText() {},
- parentNode: (n: any) => n.parent,
- nextSibling: () => null,
- patchProp() {}
- })
- const template = parse(
- readFileSync(new URL('../src/views/m/pl/plan-cert/detail.vue', import.meta.url), 'utf8')
- ).descriptor.template!.content
- assert.match(template, /<CertUpload\b[^>]*ref="uploadRef"/)
- const parent = harness('plan-cert/detail')
- await parent.init()
- parent.uploadOpen.value = true
- let successes = 0
- const App = vue.defineComponent({
- setup: () => () =>
- vue.h(Child, {
- key: recordChildKey(parent, 'CertUpload'),
- ref: parent.uploadRef,
- planId: parent.id.value,
- modelValue: parent.uploadOpen.value,
- permitted: parent.canUpload.value,
- 'data-tab': parent.route.query.tab,
- 'onUpdate:modelValue': (open: boolean) => {
- parent.uploadOpen.value = open
- },
- onSuccess: (id: string) => {
- successes++
- void parent.saved(id)
- }
- })
- })
- const app = renderer.createApp(App)
- app.mount({})
- await flush()
- const oldChild = child,
- pending = child.save()
- saved.resolve()
- if (change !== 'query') {
- parent.route.params.id = 'B'
- if (change === 'ABA') parent.route.params.id = 'A'
- } else parent.route.query.tab = 'notes'
- assert.equal(oldChild.props.planId, 'A', 'Vue child props have not flushed')
- await pending
- await flush()
- assert.equal(successes, change !== 'query' ? 0 : 1)
- assert.equal(oldChild.messages.length, change !== 'query' ? 0 : 1)
- app.unmount()
- parent.unmount()
- })
- for (const reject of [false, true])
- test(`cert detail same-record retry isolates old loader ${reject ? 'error' : 'success/finally'}`, async () => {
- const old = deferred(),
- fresh = deferred()
- let n = 0
- const h = harness('plan-cert/CertDetail', {
- usePlPlanViewDetail: detailLoader(() => (++n === 1 ? old.promise : fresh.promise))
- })
- await flush()
- const pending = h.reload()
- if (reject) old.reject(new Error('old'))
- else old.resolve({ id: 'A', planNo: 'OLD' })
- await flush()
- assert.equal(h.plan.value, null)
- assert.equal(h.loading.value, true)
- assert.equal(h.error.value, '')
- fresh.resolve({ id: 'A', planNo: 'FRESH' })
- await pending
- assert.equal(h.plan.value.planNo, 'FRESH')
- h.unmount()
- })
- test('cert detail parent sync invalidation prevents stale contract and linked callbacks', async () => {
- const contract = deferred(),
- linked = deferred()
- const h = harness('plan-cert/CertDetail', {
- usePlPlanViewDetail: detailLoader(async (id) => ({ id })),
- getContract: async () => ({ data: { id: 'C' } }),
- enrichContractReadFields: () => contract.promise,
- planApi: { list: () => linked.promise }
- })
- await flush()
- const pending = h.openContract('C')
- await flush()
- contract.resolve({ contract: { id: 'C' }, warnings: [] })
- await flush()
- h.invalidateRecord()
- linked.resolve({ data: { list: [{ id: 'OLD' }], total: 1 } })
- await pending
- assert.equal(h.contractOpen.value, false)
- assert.deepEqual(h.linkedPlans.value, [])
- assert.equal(h.linkedLoading.value, false)
- h.unmount()
- })
- test('transport local tabs retain single-line 44px targets and bordered content fits available width', () => {
- const sfc = parse(
- readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
- ).descriptor
- const style = sfc.styles.find((s) => s.scoped)!.content
- assert.match(
- style,
- /\.mp-plan-detail-page\.mp-h5-detail-page[\s\S]*?:deep\(\.van-tab\)\s*\{[^}]*flex:\s*0 0 auto !important/
- )
- assert.match(style, /:deep\(\.van-tab__text\)\s*\{[^}]*white-space:\s*nowrap/)
- assert.match(
- style,
- /:deep\(\.van-tabs__nav\)\s*\{[^}]*height:\s*44px[^}]*box-sizing:\s*border-box/
- )
- assert.match(style, /\.mp-wv-section\s*\{[^}]*min-width:\s*0[^}]*box-sizing:\s*border-box/)
- })
- for (const component of ['CertDetail', 'CertUpload'])
- test(`${component}: real parent same-tick ABA remounts, unrelated query preserves instance`, async () => {
- let child: any,
- mounts = 0,
- reads = 0
- const Child = vue.defineComponent({
- props: ['planId', 'modelValue', 'permitted'],
- setup(props, context) {
- mounts++
- const read = async (id: string) => {
- reads++
- return { id, products: [{ id: 'p', isToxic: 1 }] }
- }
- child = harness(`plan-cert/${component}`, {
- defineProps: () => props,
- defineExpose: context.expose,
- onBeforeUnmount: vue.onBeforeUnmount,
- usePlPlanViewDetail: detailLoader(read, true),
- planApi: { get: read }
- })
- return () => vue.h('child')
- }
- })
- const renderer = vue.createRenderer({
- insert(el: any, parent: any) {
- el.parent = parent
- },
- remove() {},
- createElement: () => ({}),
- createText: () => ({}),
- createComment: () => ({}),
- setText() {},
- setElementText() {},
- parentNode: (n: any) => n.parent,
- nextSibling: () => null,
- patchProp() {}
- })
- const parent = harness('plan-cert/detail')
- await parent.init()
- parent.uploadOpen.value = true
- const App = vue.defineComponent({
- setup: () => () =>
- vue.h(Child, {
- key: recordChildKey(parent, component),
- ref: component === 'CertDetail' ? parent.detailRef : parent.uploadRef,
- planId: parent.id.value,
- modelValue: parent.uploadOpen.value,
- permitted: parent.canUpload.value,
- 'data-tab': parent.route.query.tab
- })
- })
- const app = renderer.createApp(App)
- app.mount({})
- await flush()
- assert.equal(child.plan.value.id, 'A')
- const first = child
- if (component === 'CertUpload') child.products.value[0].purchaseCertUrl = 'preserved'
- parent.route.query.tab = 'info'
- await flush()
- assert.equal(child, first)
- assert.equal(reads, 1)
- assert.equal(mounts, 1)
- if (component === 'CertUpload')
- assert.equal(child.products.value[0].purchaseCertUrl, 'preserved')
- parent.route.params.id = 'B'
- parent.route.params.id = 'A'
- await flush()
- assert.equal(mounts, 2, 'an invalidated child must never be reused for same-tick ABA')
- if (component === 'CertUpload') {
- parent.openUpload({ id: 'A' })
- await flush()
- }
- assert.equal(reads, 2)
- assert.equal(child.plan.value.id, 'A')
- assert.equal(child.loading.value, false)
- app.unmount()
- parent.unmount()
- })
- for (const reject of [false, true])
- test(`cert detail reload resets copy busy while obsolete ${reject ? 'failure' : 'success'} preserves new copy`, async () => {
- const old = deferred(),
- fresh = deferred()
- const h = harness('plan-cert/CertDetail', {
- usePlPlanViewDetail: detailLoader(async (id) => ({ id }))
- })
- await flush()
- const first = h.copyAction(() => old.promise)
- assert.equal(h.copying.value, true)
- const reload = h.reload()
- assert.equal(h.copying.value, false, 'new load must synchronously reset previous copy busy')
- await reload
- const second = h.copyAction(() => fresh.promise)
- assert.equal(h.copying.value, true)
- if (reject) old.reject(new Error('old copy'))
- else old.resolve()
- await first
- assert.equal(h.copying.value, true, 'obsolete copy must not clear new copy busy')
- assert.deepEqual(h.messages, [])
- fresh.resolve()
- await second
- assert.equal(h.copying.value, false)
- h.unmount()
- })
- test('plan detail identity switch invalidates old reads and closes every record action', async () => {
- const older = deferred()
- const user = vue.reactive({ accessToken: 'old', info: { id: 'old-user' } })
- let reads = 0
- const h = harness('plan/detail', {
- useUserStore: () => user,
- planApi: {
- get: () => (++reads === 1 ? older.promise : Promise.resolve({ id: 'A', marker: 'new' }))
- },
- loadMobileDetailReads: async () => ({ errors: [] })
- })
- h.ready()
- await flush()
- h.moreOpen.value = h.voidOpen.value = h.vehicleOpen.value = true
- user.accessToken = 'new'
- assert.equal(h.moreOpen.value, false)
- assert.equal(h.voidOpen.value, false)
- assert.equal(h.vehicleOpen.value, false)
- await flush()
- older.resolve({ id: 'A', marker: 'old' })
- await flush()
- assert.equal(h.plan.value.marker, 'new')
- assert.equal(reads, 2)
- user.accessToken = ''
- assert.equal(h.plan.value, null)
- assert.equal(h.loading.value, false)
- assert.match(h.errorText.value, /登录状态/)
- await h.runAction('wait')
- assert.deepEqual(h.writes, [])
- h.unmount()
- })
- for (const change of ['token', 'user', 'logout'])
- test(`cert parent ${change} change invalidates children and pending ready confirmation`, async () => {
- const user = vue.reactive({ accessToken: 'old', info: { id: 'old-user' } })
- const confirm = deferred()
- let permissions = 0,
- invalidations = 0
- const h = harness('plan-cert/detail', {
- useUserStore: () => user,
- showCrmConfirmDialog: () => confirm.promise,
- fetchGetMenuCodes: async () => (++permissions === 1 ? { all: true } : { menuCodes: [] })
- })
- await h.init()
- h.detailRef.value = h.uploadRef.value = { invalidateRecord: () => invalidations++ }
- h.uploadOpen.value = true
- const pending = h.setReady({ id: 'A' }, true)
- await flush()
- const epoch = h.recordEpoch.value
- if (change === 'token') user.accessToken = 'new'
- else if (change === 'user') user.info = { id: 'new-user' }
- else user.accessToken = ''
- assert.equal(h.uploadOpen.value, false)
- assert.equal(h.canReady.value, false)
- assert.ok(h.recordEpoch.value > epoch)
- assert.ok(invalidations >= 2)
- confirm.resolve()
- await pending
- await flush()
- assert.deepEqual(h.writes, [])
- assert.equal(h.canUpload.value, false)
- assert.equal(permissions, change === 'logout' ? 1 : 2)
- h.unmount()
- })
- test('cert parent old permission read cannot grant access after identity changes', async () => {
- const user = vue.reactive({ accessToken: 'old', info: { id: 'user' } })
- const old = deferred()
- let reads = 0
- const h = harness('plan-cert/detail', {
- useUserStore: () => user,
- fetchGetMenuCodes: () => (++reads === 1 ? old.promise : Promise.resolve({ menuCodes: [] }))
- })
- const init = h.init()
- user.accessToken = 'new'
- await flush()
- old.resolve({ all: true })
- await init
- assert.equal(h.canUpload.value, false)
- assert.equal(h.canReady.value, false)
- assert.equal(h.canPrice.value, false)
- assert.equal(reads, 2)
- h.unmount()
- })
- test('cert detail requests mobile metadata once and passes that snapshot to the shared read loader', async () => {
- const snapshot = { id: 'A', products: [], deliveries: [] }
- const reads: any[] = [],
- snapshots: any[] = []
- const loader = detailLoader(async () => snapshot)
- const h = harness('plan-cert/CertDetail', {
- planApi: {
- get: async (...args: any[]) => {
- reads.push(args)
- return { data: snapshot }
- }
- },
- usePlPlanViewDetail: () => {
- const detail = loader()
- const load = detail.loadDetail
- detail.loadDetail = (id: string, options: any) => {
- snapshots.push(options?.initialPlan)
- return load(id)
- }
- return detail
- }
- })
- await flush()
- assert.deepEqual(reads, [['A', true]])
- assert.equal(snapshots[0], snapshot)
- assert.equal(h.plan.value.id, 'A')
- h.unmount()
- })
|