434 lines
14 KiB
TypeScript
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)
|
|
}
|
|
})
|