Files
shanxia2.0/web-admin/scripts/mobile-crm-approvers.test.ts
T

434 lines
14 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import * as vue from 'vue'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import { createContractPicker } from '../src/views/m/crm/contract/mobileFormState'
const pages = ['contract/detail', 'quote/detail']
const users = [
{ value: 'A', nickName: '张三', userName: 'Alice', userNo: 'NO001' },
{ value: 'B', nickName: '李四', userName: 'Bob', userNo: 'NO002' },
{ value: 'C', nickName: '王五', userName: 'Carol', userNo: 'NO003' }
]
function source(page: string) {
return parse(readFileSync(new URL(`../src/views/m/crm/${page}.vue`, import.meta.url), 'utf8'))
.descriptor
}
function evaluate(page: string, names: string[], bindings: any, watchers = false) {
const ast = ts.createSourceFile(
'page.ts',
source(page).scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const statements = names.map((name) => {
const node = ast.statements.find(
(n) =>
(ts.isFunctionDeclaration(n) && n.name?.text === name) ||
(ts.isVariableStatement(n) &&
n.declarationList.declarations.some((d) => d.name.getText(ast) === name))
)
assert.ok(node, `${page}: production declaration ${name}`)
return node.getText(ast)
})
if (watchers)
statements.push(
...ast.statements
.filter(
(n) =>
ts.isExpressionStatement(n) &&
/^(watch\(|onBeforeUnmount\()/.test(n.getText(ast)) &&
/submitOpen|closeSubmitSession|closeRecordActions|resetRecord/.test(n.getText(ast))
)
.map((n) => n.getText(ast))
)
const code = ts.transpile(statements.join('\n'), { target: ts.ScriptTarget.ESNext })
return new Function(...Object.keys(bindings), code + ';return {' + names.join(',') + '}')(
...Object.values(bindings)
)
}
function deferred() {
let resolve!: (x: any) => void
let reject!: (x: any) => void
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
function parentHarness(
page: string,
fetchUsers = async () => users,
defaults = async () => ({ data: { status: '0', approverIds: [] } })
) {
const writes: any[] = []
let failSubmit = false
let unmount = () => {}
const bindings: any = {
props: { embedded: false, recordId: '' },
ref: vue.ref,
computed: vue.computed,
reactive: vue.reactive,
watch: vue.watch,
submitOpen: vue.ref(false),
submitForm: vue.ref({ approverIds: [], remark: '' }),
approverPickerOpen: vue.ref(false),
userOptions: vue.ref([]),
acting: vue.ref(false),
route: vue.reactive({
fullPath: '/original',
name: page.startsWith('quote') ? 'MpCrmQuoteDetail' : 'MpCrmContractDetail',
get path() {
return `/m/crm/${page.startsWith('quote') ? 'quote' : 'contract'}/${this.params.id}`
},
params: { id: page.startsWith('quote') ? 'quote' : 'contract' },
query: { from: '' }
}),
shellReady: true,
disposed: false,
loading: vue.ref(false),
moreOpen: vue.ref(false),
approveOpen: vue.ref(false),
executeOpen: vue.ref(false),
completeOpen: vue.ref(false),
voidReasonPickerOpen: vue.ref(false),
voidOpen: vue.ref(false),
voidReasonOpen: vue.ref(false),
products: vue.ref([]),
approvalList: vue.ref([]),
approvalError: vue.ref(''),
approvalLoading: vue.ref(false),
approvalRequestSeq: 0,
userStore: vue.reactive({ accessToken: 'token', info: { id: 'user' } }),
categoryPaths: vue.ref(new Map()),
errorText: vue.ref(''),
uploadOperation: null,
linkedPlansRequestSeq: 0,
linkedPlans: vue.ref([]),
linkedPlansTotal: vue.ref(0),
linkedPlansLoading: vue.ref(false),
linkedPlansError: vue.ref(false),
activeTab: vue.ref('contract'),
partiesExpanded: vue.ref(false),
detailRequestSeq: 0,
categoryRequestSeq: 0,
fabActions: vue.ref([{ key: 'submit' }]),
contract: vue.ref({ id: 'contract' }),
quote: vue.ref({ id: 'quote' }),
fetchUserOptions: fetchUsers,
getContractApproverSetting: defaults,
getQuoteApproverSetting: defaults,
ElMessage: { warning() {}, error() {}, success() {} },
showFailToast() {},
showSuccessToast() {},
loadDetail: async () => {},
reload() {},
clearSelection() {},
selectMode: vue.ref(true),
batchTargetIds: vue.ref([]),
batchSkipped: vue.ref(0),
selectedRows: () => [
{ id: 'draft', status: 'draft' },
{ id: 'approved', status: 'approved' }
],
submitContract: async (...args: any[]) => {
writes.push(args)
if (failSubmit) throw Error('offline')
},
submitQuote: async (...args: any[]) => {
writes.push(args)
if (failSubmit) throw Error('offline')
},
batchSubmitContract: async (...args: any[]) => {
writes.push(args)
if (failSubmit) throw Error('offline')
return 1
},
onBeforeUnmount: (fn: any) => {
unmount = fn
}
}
const scope = vue.effectScope()
const batch = page.includes('List')
const names = [
...(!batch
? [
'operationEpoch',
'currentRecordId',
'captureOperation',
'isCurrentOperation',
'closeRecordActions',
'resetRecord'
]
: []),
'approversLoading',
'approversError',
'submitGeneration',
'approverRows',
'selectedApprovers',
'approverSearchText',
'approverDescription',
'confirmApprovers',
'closeSubmitSession',
'loadUserOptions',
'resolvePrefillApproverIds',
'loadSubmitApprovers',
'retrySubmitApprovers',
'formatApproverOptionLabel',
batch ? 'openBatchSubmit' : 'openSubmitSheet',
batch ? 'confirmBatchSubmit' : 'confirmSubmit'
]
const api = scope.run(() => evaluate(page, names, bindings, true))
return {
...bindings,
...api,
writes,
open: api[batch ? 'openBatchSubmit' : 'openSubmitSheet'],
submit: api[batch ? 'confirmBatchSubmit' : 'confirmSubmit'],
fail: () => {
failSubmit = true
},
dispose: () => {
unmount()
scope.stop()
}
}
}
function pickerHarness(parent: any) {
const props = vue.reactive({
show: true,
title: '审批人',
rows: parent.approverRows.value,
value: parent.selectedApprovers.value,
label: parent.formatApproverOptionLabel,
description: parent.approverSearchText,
searchText: parent.approverSearchText,
multiple: true
})
const script = source('contract/ContractPickerSheet').scriptSetup!.content.replace(
/^\s*import[^\n]+\n/gm,
''
)
const code = ts.transpile(script, { target: ts.ScriptTarget.ESNext })
const scope = vue.effectScope()
const bindings: any = {
ref: vue.ref,
computed: vue.computed,
reactive: vue.reactive,
watch: vue.watch,
createContractPicker,
defineProps: () => props,
withDefaults: (p: any) => p,
defineEmits: () => (event: string, value: any) => {
if (event === 'confirm') parent.confirmApprovers(value)
else if (event === 'update:show') props.show = value
},
onBeforeUnmount() {}
}
const api = scope.run(() =>
new Function(
...Object.keys(bindings),
code + ';return { pick, confirm, keyword, visibleRows, picker }'
)(...Object.values(bindings))
)
return { ...api, props, dispose: () => scope.stop() }
}
for (const page of pages) {
test(`${page}: actual picker confirm/cancel preserves order, searches name/account/no, and reselect appends`, async () => {
const h = parentHarness(page)
await h.open()
const p = pickerHarness(h)
const row = (id: string) => h.approverRows.value.find((u: any) => u.id === id)
p.pick(row('B'))
p.pick(row('A'))
p.pick(row('C'))
assert.deepEqual(h.submitForm.value.approverIds, [])
p.props.show = false
assert.deepEqual(h.submitForm.value.approverIds, [])
p.props.show = true
for (const term of [' 张三 ', ' ALICE ', ' no001 ']) {
p.keyword.value = term
assert.deepEqual(
p.visibleRows.value.map((u: any) => u.id),
['A']
)
}
p.pick(row('B'))
p.pick(row('A'))
p.keyword.value = 'NO003'
p.pick(row('C'))
p.keyword.value = 'missing'
assert.equal(p.visibleRows.value.length, 0)
p.confirm()
assert.deepEqual(h.submitForm.value.approverIds, ['B', 'A', 'C'])
p.props.value = h.selectedApprovers.value
p.props.show = true
assert.deepEqual(
p.picker.selected.map((u: any) => u.id),
['B', 'A', 'C']
)
p.pick(row('A'))
p.pick(row('A'))
p.confirm()
assert.deepEqual(h.submitForm.value.approverIds, ['B', 'C', 'A'])
h.confirmApprovers([{ id: 'B' }, { id: '' }, { id: 'B' }, { id: 'C' }])
assert.deepEqual(h.submitForm.value.approverIds, ['B', 'C'])
h.approverPickerOpen.value = true
h.submitOpen.value = false
assert.equal(h.approverPickerOpen.value, false)
p.dispose()
h.dispose()
})
test(`${page}: candidate failure is visible and retry recovers; optional defaults fail safely`, async () => {
let fail = true
const h = parentHarness(
page,
async () => {
if (fail) throw Error('offline')
return users
},
async () => {
throw Error('optional unavailable')
}
)
await h.open()
assert.equal(h.submitOpen.value, true)
assert.match(h.approversError.value, /offline/)
await h.submit()
assert.equal(h.writes.length, 0)
fail = false
await h.retrySubmitApprovers()
assert.equal(h.approversError.value, '')
assert.equal(h.approverRows.value.length, 3)
h.confirmApprovers([{ id: 'B' }, { id: 'A' }])
h.fail()
h.approverPickerOpen.value = true
await h.submit()
assert.equal(h.approverPickerOpen.value, false)
assert.equal(h.submitOpen.value, true)
assert.deepEqual(h.submitForm.value.approverIds, ['B', 'A'])
const args = h.writes[0]
if (page.startsWith('quote')) assert.deepEqual(args[0].approverIds, ['B', 'A'])
else assert.deepEqual(args[1], ['B', 'A'])
if (page.includes('List')) {
assert.deepEqual(args[0], ['draft'])
assert.equal(h.batchSkipped.value, 1)
}
h.dispose()
})
test(`${page}: old candidates/defaults never overwrite reentered or closed submit sessions`, async () => {
const first = deferred()
let count = 0
const h = parentHarness(page, () =>
++count === 1 ? (first.promise as any) : Promise.resolve(users)
)
const old = h.open()
await h.open()
h.confirmApprovers([{ id: 'C' }])
first.resolve([{ value: 'stale' }])
await old
assert.deepEqual(h.submitForm.value.approverIds, ['C'])
assert.equal(h.approverRows.value.length, 3)
h.dispose()
const setting = deferred()
let defaultsCount = 0
const second = parentHarness(
page,
async () => users,
() =>
++defaultsCount === 1
? (setting.promise as any)
: Promise.resolve({ data: { status: '0', approverIds: ['B', 'invalid', 'A', 'B'] } })
)
const oldDefaults = second.open()
await Promise.resolve()
await Promise.resolve()
second.submitOpen.value = false
await second.open()
assert.deepEqual(second.submitForm.value.approverIds, ['B', 'A'])
setting.resolve({ data: { status: '0', approverIds: ['C'] } })
await oldDefaults
assert.deepEqual(second.submitForm.value.approverIds, ['B', 'A'])
second.approverPickerOpen.value = true
if (page.includes('List')) second.route.fullPath = '/another'
else second.route.query.from = 'approve'
await vue.nextTick()
assert.equal(second.submitOpen.value, false)
assert.equal(second.approverPickerOpen.value, false)
second.dispose()
})
test(`${page}: descriptions only supplement labels with distinct account numbers`, async () => {
const h = parentHarness(page)
assert.equal(
h.approverDescription({ nickName: '方金强', userName: '方金强', userNo: '方金强' }),
''
)
assert.equal(
h.approverDescription({ nickName: '管理员', userName: 'admin', userNo: 'admin' }),
''
)
assert.equal(
h.approverDescription({ nickName: '张三', userName: 'alice', userNo: 'NO001' }),
'编号:NO001'
)
assert.match(
h.approverSearchText({ nickName: '张三', userName: 'alice', userNo: 'NO001' }),
/张三.*alice.*NO001/
)
h.dispose()
})
test(`${page}: closed or unmounted candidates and stale failures cannot clear a newer loading session`, async () => {
const requests = [deferred(), deferred()]
let count = 0
const h = parentHarness(page, () => requests[count++].promise as any)
const old = h.open()
h.submitOpen.value = false
const fresh = h.open()
requests[0].reject(Error('old failure'))
await old
assert.equal(h.approversError.value, '')
assert.equal(h.approversLoading.value, true)
h.approverPickerOpen.value = true
h.dispose()
requests[1].resolve(users)
await fresh
assert.equal(h.approverPickerOpen.value, false)
assert.deepEqual(h.userOptions.value, [])
})
test(`${page}: rendered picker wires local selection and retry to production handlers`, () => {
const template = source(page).template!.content
assert.match(
template,
/<ContractPickerSheet[\s\S]*?:value="selectedApprovers"[\s\S]*?@confirm="confirmApprovers"/
)
assert.match(template, /@click="retrySubmitApprovers"/)
assert.doesNotMatch(template, /v-model="submitForm.approverIds"/)
})
}
test('quote expiry matches desktop day boundaries including date-only, datetime and invalid values', () => {
class FixedDate extends Date {
static now() {
return new Date('2026-10-21T04:00:00Z').getTime()
}
}
const quote = vue.ref<any>({})
const { expireText } = evaluate('quote/detail', ['expireText'], {
computed: vue.computed,
quote,
Date: FixedDate
})
for (const [value, expected] of [
['2026-10-23', '2026-10-23(剩余2天)'],
['2026-10-21', '2026-10-21(今天到期)'],
['2026-10-19', '2026-10-19(已过期)'],
['2026-10-21T04:00:00Z', '2026-10-21T04:00:00Z(今天到期)'],
['', '—'],
['bad', 'bad']
]) {
quote.value = { expireDate: value }
assert.equal(expireText.value, expected)
}
})