Files
shanxia2.0/web-admin/scripts/mobile-contract-list-parity.test.ts

436 lines
15 KiB
TypeScript

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<string, any> = {}
) {
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<string, any> = { ...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()
}
})