1051 lines
36 KiB
TypeScript
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()
|
|
})
|