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