Files
shanxia2.0/web-admin/scripts/mobile-pl-record-identity.test.ts

1051 lines
36 KiB
TypeScript

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()
})