import assert from 'node:assert/strict' import test from 'node:test' import { existsSync, readFileSync } from 'node:fs' import * as Vue from 'vue' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import * as card from '../src/utils/mp-crm-contract-card' import { buildContractColumnFilterDefs } from '../src/views/crm/contract/columnFilterDefs' async function model() { assert.ok( existsSync(new URL('../src/views/m/crm/contract/listState.ts', import.meta.url)), 'contract query model exists' ) return import('../src/views/m/crm/contract/listState') } async function harness( page: string, props: any = { mode: 'manage' }, overrides: Record = {} ) { const file = new URL(`../src/views/m/crm/contract/${page}.vue`, import.meta.url) assert.ok(existsSync(file), `${page} must exist`) const source = readFileSync(file, 'utf8') const ast = ts.createSourceFile( 'page.ts', parse(source).descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s)) const names = statements.flatMap((s) => ts.isFunctionDeclaration(s) && s.name ? [s.name.text] : ts.isVariableStatement(s) ? s.declarationList.declarations.flatMap((d) => ts.isIdentifier(d.name) ? [d.name.text] : ts.isObjectBindingPattern(d.name) ? d.name.elements.map((e) => e.name.getText(ast)) : [] ) : [] ) const bindings: Record = { ...Vue } for (const s of ast.statements) if (ts.isImportDeclaration(s)) { if (s.importClause?.name) bindings[s.importClause.name.text] = () => {} const n = s.importClause?.namedBindings if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {} } const requests: any[] = [], events: any[] = [], pushes: any[] = [], facets: any[] = [] const user = Vue.reactive({ accessToken: 'token', info: { id: 'u1' }, getUserInfo: { id: 'u1' } }) Object.assign( bindings, card, existsSync(new URL('../src/views/m/crm/contract/listState.ts', import.meta.url)) ? await model() : {}, { buildContractColumnFilterDefs, defineProps: () => props, defineEmits: () => (...args: any[]) => events.push(args), route: Vue.reactive({ fullPath: '/m/crm/contract' }), ElMessage: { error() {}, warning() {} }, useRouter: () => ({ push: (v: any) => pushes.push(v) }), useUserStore: () => user, useDictStore: () => ({ getOptions: () => [{ value: 'invalid', label: '作废' }], getLabel: (_: any, value: any) => value, getTagType: () => 'info' }), useListReturnState: () => ({ restoring: Vue.ref(false), restore: async () => {} }), useIntersectionObserver: () => {}, useDebounceFn: (fn: any) => fn, listContract: async (q: any) => { requests.push(q) return { rows: [], total: 0 } }, fetchContractColumnFacetValues: async (q: any) => { facets.push(q) return { list: [ { value: 'A', label: '甲' }, { value: 'B', label: '乙' } ] } }, HttpError: class extends Error {}, ApiStatus: { forbidden: 403 }, onMounted: () => {}, onBeforeUnmount: () => {} }, overrides ) for (const key of Object.keys(bindings)) if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key] const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), { target: ts.ScriptTarget.ESNext }) const scope = Vue.effectScope() const api = scope.run(() => new Function(...Object.keys(bindings), `${code}; return {${names.join(',')}}`)( ...Object.values(bindings) ) ) return { api, requests, events, pushes, facets, user, source, stop: () => scope.stop() } } test('contract default field filters serialize Web parameters and preserve explicit status in both modes', async () => { const m = await model() for (const mode of ['manage', 'approve'] as const) for (const view of ['all', 'submitted', 'approved'] as const) { const input = { mode, view, keyword: ' 客户 ', query: {}, currentUserId: 'u1', pageNum: 2, pageSize: 20 } const params = m.buildMobileContractQuery(input) assert.equal(params.excludeStatus, 'invalid') assert.equal(params.includeProducts, true) assert.equal(params.keyword, '客户') assert.equal( params.pendingApprovalUserId, mode === 'approve' && view === 'submitted' ? 'u1' : undefined ) assert.equal( params.approvedByUserId, mode === 'approve' && view === 'approved' ? 'u1' : undefined ) const explicit = m.buildMobileContractQuery({ ...input, query: { statusIn: 'invalid,approved', contractNoIn: 'C1', contractNameIn: '合同', amountOp: 'gte', amount: '0', beginTime: '2026-01-01', signDateEnd: '2026-02-01', remark: 'hidden' } }) assert.equal(explicit.excludeStatus, undefined) assert.equal(explicit.statusIn, 'invalid,approved') assert.equal(explicit.contractNoIn, 'C1') assert.equal(explicit.amount, '0') assert.equal(explicit.beginTime, '2026-01-01') assert.equal(explicit.signDateEnd, '2026-02-01') assert.equal(explicit.remark, undefined) } const defs = m.contractFilterDefs(() => []) assert.equal(defs.length, 11) const draft = m.createContractFilterDraft( { contractNoIn: 'C1,C2', freightOp: 'eq', freight: '0' }, defs ) assert.deepEqual(m.contractFilterQuery(draft, defs), { contractNoIn: 'C1,C2', freightOp: 'eq', freight: '0' }) draft.amount = { op: 'between', min: '20', max: '10', value: '' } assert.throws(() => m.contractFilterQuery(draft, defs), /最小值不能大于最大值/) }) test('list errors remain visible, retries repeat the failed page, and identity change rejects stale results', async () => { const requests: any[] = [] const h = await harness( 'MpContractList', { mode: 'manage' }, { listContract: (q: any) => new Promise((resolve, reject) => requests.push({ q, resolve, reject })) } ) try { const first = h.api.fetchPage(true) requests[0].reject(new Error('离线')) await first assert.equal(h.api.errorText?.value, '离线') const retry = h.api.fetchPage(true) requests[1].resolve({ rows: [{ id: '1' }], total: 2 }) await retry assert.equal(h.api.errorText.value, '') const more = h.api.fetchPage(false) requests[2].reject(new Error('下一页离线')) await more assert.equal(h.api.contracts.value.length, 1) const moreRetry = h.api.fetchPage(false) assert.equal(requests[3].q.pageNum, 2) h.user.accessToken = 'other' assert.deepEqual(h.api.contracts.value, []) requests[3].resolve({ rows: [{ id: 'stale' }], total: 2 }) await moreRetry assert.deepEqual(h.api.contracts.value, []) assert.equal(h.api.loading.value, false) } finally { h.stop() } }) test('list keeps default business card data and single actions with no multi-select or batch state', async () => { const h = await harness('MpContractList') try { assert.equal(h.api.selectMode, undefined) assert.equal(h.api.openBatchSubmit, undefined) h.api.contracts.value = [ { id: '1', contractNo: 'C1', contractName: '合同', customerName: '客户', status: 'draft', amount: 100, freight: 10, barrelDeposit: 5, attachmentFiles: 'a,b', products: [{ productName: '产品', quantity: 2 }], ownerUserName: '负责人', createTime: '2026' } ] const fields = [...h.api.cards.value[0].previewFields, ...h.api.cards.value[0].extraFields] assert.ok(fields.find((f: any) => f.key === 'totalContractAmount')?.value.includes('115')) assert.equal(fields.find((f: any) => f.key === 'contractNo')?.value, 'C1') assert.equal(fields.find((f: any) => f.key === 'attachmentFiles')?.value, '2 个') h.api.openDetailAction('1', 'copy') assert.deepEqual(h.pushes[0], { path: '/m/crm/contract/1', query: { from: 'manage', action: 'copy' } }) } finally { h.stop() } }) test('approval list actions require backend approval flag and approving state', async () => { const h = await harness('MpContractList', { mode: 'approve' }) try { for (const status of ['draft', 'approving', 'approved']) for (const canApprove of [false, true]) { const card = cardFrom(status, canApprove) const actions = h.api.cardActions(card).map((a: any) => a.key) assert.equal(actions.includes('approve'), status === 'approving' && canApprove) } } finally { h.stop() } function cardFrom(status: string, canApprove: boolean) { return card.buildContractCard({ id: '1', status, canApprove }) } }) test('three contract facets use surrounding scope, exclude themselves, and close/reopen invalidates pending loads', async () => { const m = await model() assert.deepEqual( m.contractFacetQuery( { contractNameIn: '旧', statusIn: 'draft', pageNum: 2, pageSize: 20, includeProducts: true }, 'contractName' ), { statusIn: 'draft', field: 'contractName', limit: 500 } ) let resolve!: (value: any) => void let count = 0 const props = Vue.reactive({ modelValue: true, query: { contractNameIn: '已选' }, context: { statusIn: 'draft', contractNameIn: '旧', pageNum: 2 } }) const h = await harness('ContractFilters', props, { fetchContractColumnFacetValues: () => ++count === 1 ? new Promise((r) => { resolve = r }) : Promise.resolve({ list: [{ value: '新', label: '新候选' }] }) }) try { const first = h.api.loadFacet('contractName') props.modelValue = false await Vue.nextTick() assert.equal(h.api.facetLoading.contractName, false) props.modelValue = true await Vue.nextTick() await h.api.loadFacet('contractName') resolve({ list: [{ value: '旧', label: '旧候选' }] }) await first h.api.searches.contractName = '新' assert.deepEqual( h.api.optionsFor('contractName').map((o: any) => o.value), ['新'] ) assert.deepEqual(h.api.draft.value.contractName, ['已选']) } finally { h.stop() } }) test('actual list and filter templates compile and render only single-record actions', async () => { const { compileScript, compileTemplate } = await import('@vue/compiler-sfc') const { renderToString } = await import('vue/server-renderer') for (const page of ['MpContractList', 'ContractFilters']) { const props = page === 'MpContractList' ? { mode: 'manage' } : { modelValue: true, query: {}, context: {} } const h = await harness(page, props) try { const parsed = parse(h.source) assert.deepEqual(parsed.errors, []) compileScript(parsed.descriptor, { id: page }) const compiled = compileTemplate({ id: page, filename: page + '.vue', source: parsed.descriptor.template!.content }) assert.deepEqual(compiled.errors, []) const exports: any = {} const stub = { setup: (_: any, { slots }: any) => () => Vue.h( 'div', Object.values(slots).flatMap((slot: any) => slot()) ) } new Function( 'require', 'exports', ts.transpile(compiled.code, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext }) )(() => ({ ...Vue, resolveComponent: () => stub }), exports) if (page === 'MpContractList') { h.api.contracts.value = [ { id: '1', contractNo: '唯一编号', contractName: '唯一名称', customerName: '唯一客户', status: 'draft' } ] h.api.loading.value = false const html = await renderToString( Vue.createSSRApp({ setup: () => () => exports.render(Vue.proxyRefs(h.api), []) }) ) for (const text of ['唯一编号', '唯一名称', '唯一客户', '复制', '提交审批']) assert.ok(html.includes(text), text) for (const text of ['多选', '已选', '批量', '字段设置', '列统计', '清空筛选', '刷新']) assert.ok(!html.includes(text), text) } } finally { h.stop() } } }) test('identity reset clears a previous forbidden state so a network error remains retryable', async () => { class ForbiddenError extends Error { code = 403 } let forbidden = true const h = await harness( 'MpContractList', { mode: 'manage' }, { HttpError: ForbiddenError, listContract: async () => { if (forbidden) throw new ForbiddenError('无权限') throw new Error('网络失败') } } ) try { await h.api.fetchPage(true) assert.equal(h.api.forbidden.value, true) h.user.accessToken = 'new' assert.equal(h.api.forbidden.value, false) forbidden = false await h.api.fetchPage(true) assert.equal(h.api.forbidden.value, false) assert.equal(h.api.errorText.value, '网络失败') } finally { h.stop() } }) test('contract filter pickers preserve drafts on cancel and serialize confirmed enum, number and date values', async () => { const props = Vue.reactive({ modelValue: true, query: { contractNameIn: 'A' }, context: {} }) const h = await harness('ContractFilters', props) try { h.api.openEnum('contractName') h.api.toggleEnum('B') h.api.enumOpen.value = false assert.deepEqual(h.api.draft.value.contractName, ['A']) h.api.openEnum('contractName') assert.deepEqual(h.api.enumDraft.value, ['A']) h.api.toggleEnum('B') h.api.confirmEnum() await Vue.nextTick() assert.equal(h.api.enumSummary('contractName'), '甲、乙') h.api.openSingle('numberOp', 'amount') h.api.confirmSingle({ selectedValues: ['gte'] }) h.api.draft.value.amount.value = '100' h.api.openDate('signDate', 'from') h.api.confirmDate({ selectedValues: ['2026', '10', '24'] }) h.api.apply() assert.deepEqual(h.events.find((e: any) => e[0] === 'apply')[1], { contractNameIn: 'A,B', amountOp: 'gte', amount: '100', signDateBegin: '2026-10-24' }) h.api.openEnum('contractName') h.api.openSingle('sortField') h.api.openDate('signDate', 'to') props.modelValue = false await Vue.nextTick() assert.equal(h.api.enumOpen.value, false) assert.equal(h.api.singleOpen.value, false) assert.equal(h.api.dateOpen.value, false) } finally { h.stop() } })