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, / { class FixedDate extends Date { static now() { return new Date('2026-10-21T04:00:00Z').getTime() } } const quote = vue.ref({}) 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) } })