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

450 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-quote-card'
import { buildQuoteColumnFilterDefs } from '../src/views/crm/quote/columnFilterDefs'
async function model() {
assert.ok(
existsSync(new URL('../src/views/m/crm/quote/listState.ts', import.meta.url)),
'mobile quote query model must exist'
)
return import('../src/views/m/crm/quote/listState')
}
test('all/submitted/accepted use the Web implicit scopes and explicit status overrides in both modes', async () => {
const m = await model()
for (const mode of ['manage', 'approve'] as const)
for (const view of ['all', 'submitted', 'accepted'] as const) {
const base = {
mode,
view,
keyword: ' 搜索 ',
currentUserId: 'u1',
pageNum: 2,
pageSize: 20,
visible: m.quoteColumnDefs.map((d) => d.prop)
}
const q = m.buildMobileQuoteQuery({
...base,
query: { quoteNo: '旧', quoteTitle: '旧', customerName: '旧', quoteNoIn: 'Q1' }
})
assert.equal(q.excludeStatus, 'invalid')
assert.equal(q.keyword, '搜索')
assert.equal(q.quoteTitle, undefined)
assert.equal(q.quoteNoIn, 'Q1')
assert.equal(q.status, view === 'submitted' ? 'submitted' : undefined)
assert.equal(q.pendingApprovalUserId, view === 'submitted' ? 'u1' : undefined)
assert.equal(q.approvedByUserId, view === 'accepted' ? 'u1' : undefined)
const explicit = m.buildMobileQuoteQuery({ ...base, query: { statusIn: 'invalid,approved' } })
assert.equal(explicit.excludeStatus, undefined)
assert.equal(explicit.status, undefined)
assert.equal(explicit.statusIn, 'invalid,approved')
}
})
test('filter serialization maps dates, enum and numbers and rejects reversed ranges; hidden filters and sorts are removed', async () => {
const m = await model()
const defs = buildQuoteColumnFilterDefs(() => [])
const draft = m.createQuoteFilterDraft(
{
quoteNoIn: 'Q1,Q2',
quoteDateBegin: '2026-01-01',
beginTime: '2026-02-01',
finalAmountOp: 'gte',
finalAmount: '0'
},
defs
)
assert.deepEqual(m.quoteFilterQuery(draft, defs), {
quoteNoIn: 'Q1,Q2',
quoteDateBegin: '2026-01-01',
beginTime: '2026-02-01',
finalAmountOp: 'gte',
finalAmount: '0'
})
draft.finalAmount = { op: 'between', min: '5', max: '2', value: '' }
assert.throws(() => m.quoteFilterQuery(draft, defs), /最小值不能大于最大值/)
draft.finalAmount = { op: 'between', min: '', max: '', value: '' }
draft.quoteDate = { from: '2026-03-01', to: '2026-02-01' }
assert.throws(() => m.quoteFilterQuery(draft, defs), /开始日期不能晚于结束日期/)
assert.deepEqual(
m.cleanQuoteQuery(
{
quoteNoIn: 'Q',
quoteTitle: 'T',
statusIn: 'invalid',
beginTime: '2026',
sortField: 'createTime',
sortOrder: 'desc'
},
[]
),
{ quoteNoIn: 'Q', quoteTitle: 'T' }
)
})
async function harness(
page: string,
props: any = { mode: 'manage' },
overrides: Record<string, any> = {}
) {
const file = new URL(`../src/views/m/crm/quote/${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,
await model(),
{
buildQuoteColumnFilterDefs,
defineProps: () => props,
defineEmits:
() =>
(...args: any[]) =>
events.push(args),
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,
listQuote: async (q: any) => {
requests.push(q)
return { rows: [], total: 0 }
},
fetchQuoteColumnFacetValues: 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('both list modes use fixed Web defaults regardless of old mobile preferences', async () => {
const m = await model()
Object.assign(globalThis, {
localStorage: {
getItem: () => '[]',
setItem: () => {
throw new Error('must not persist columns')
}
}
})
try {
for (const mode of ['manage', 'approve']) {
const h = await harness('MpQuoteList', { mode })
try {
assert.deepEqual([...h.api.visibleColumns.value], m.defaultQuoteColumns)
assert.equal(h.api.visibleColumns.value.length, 11)
assert.equal(h.api.selecting, undefined)
assert.equal(h.api.columnsOpen, undefined)
h.api.applyFilters({ statusIn: 'invalid', contactEmail: 'hidden', quoteTitle: '主题' })
assert.deepEqual(h.api.query.value, { statusIn: 'invalid', quoteTitle: '主题' })
h.api.quotes.value = [{ id: '1' }]
h.user.accessToken = 'other'
assert.deepEqual(h.api.quotes.value, [])
} finally {
h.stop()
}
}
} finally {
delete (globalThis as any).localStorage
}
})
test('failed requests provide a retryable error and a successful retry clears it without skipping page', async () => {
let failing = true
const requested: any[] = []
const h = await harness(
'MpQuoteList',
{ mode: 'manage' },
{
listQuote: async (q: any) => {
requested.push(q)
if (failing) throw new Error('离线')
return { rows: [{ id: '1' }], total: 2 }
}
}
)
try {
await h.api.fetchPage(true)
assert.equal(h.api.errorText.value, '离线')
failing = false
await h.api.fetchPage(true)
assert.equal(h.api.errorText.value, '')
assert.equal(h.api.quotes.value.length, 1)
failing = true
await h.api.fetchPage(false)
failing = false
await h.api.fetchPage(false)
assert.deepEqual(
requested.map((q) => q.pageNum),
[1, 1, 2, 2]
)
} finally {
h.stop()
}
})
test('quote facet excludes itself and page context, searches locally, and closing pending load permits retry', async () => {
const props = Vue.reactive({
modelValue: true,
query: { customerNameIn: 'A' },
context: { keyword: '酸', customerNameIn: 'A', pendingApprovalUserId: 'u1', pageNum: 2 },
visibleColumns: ['customerName']
})
let resolve!: (value: any) => void
let calls = 0
const h = await harness('QuoteFilters', props, {
fetchQuoteColumnFacetValues: (q: any) => {
calls++
h.facets.push(q)
return calls === 1
? new Promise((r) => {
resolve = r
})
: Promise.resolve({ list: [{ value: 'B', label: '乙' }] })
}
})
try {
const first = h.api.loadFacet('customerName')
props.modelValue = false
await Vue.nextTick()
assert.equal(h.api.facetLoading.customerName, false)
props.modelValue = true
await Vue.nextTick()
await h.api.loadFacet('customerName')
resolve({ list: [{ value: 'old', label: '旧' }] })
await first
assert.deepEqual(h.facets.at(-1), {
keyword: '酸',
pendingApprovalUserId: 'u1',
field: 'customerName',
limit: 500
})
h.api.searches.customerName = '乙'
assert.deepEqual(
h.api.optionsFor('customerName').map((o: any) => o.value),
['B']
)
assert.deepEqual(h.api.draft.value.customerName, ['A'])
} finally {
h.stop()
}
})
test('tab filters are independent and single-record copy retains its route', async () => {
const h = await harness('MpQuoteList')
try {
h.api.keyword.value = '全部关键词'
h.api.query.value = { quoteNoIn: 'Q' }
h.api.view.value = 'submitted'
h.api.onViewChange('submitted')
assert.equal(h.api.keyword.value, '')
h.api.query.value = { customerNameIn: '客户' }
h.api.view.value = 'all'
h.api.onViewChange('all')
assert.equal(h.api.keyword.value, '全部关键词')
assert.deepEqual(h.api.query.value, { quoteNoIn: 'Q' })
h.api.openDetailAction('1', 'copy')
assert.deepEqual(h.pushes[0], {
path: '/m/crm/quote/1',
query: { from: 'manage', action: 'copy' }
})
} finally {
h.stop()
}
})
test('actual list and filter SFC retain default fields without removed list tools', async () => {
const { compileScript, compileTemplate } = await import('@vue/compiler-sfc')
const { renderToString } = await import('vue/server-renderer')
const h = await harness('MpQuoteList')
try {
h.api.quotes.value = [
{
id: '1',
quoteTitle: '固定主题(固定单号)',
quoteNo: '固定单号',
status: '唯一状态',
createDeptName: '唯一部门',
updateByName: '唯一更新人'
}
]
h.api.loading.value = false
const descriptor = parse(h.source).descriptor
compileScript(descriptor, { id: 'quote-list' })
const template = compileTemplate({
id: 'quote-list',
filename: 'MpQuoteList.vue',
source: descriptor.template!.content
})
assert.deepEqual(template.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(template.code, {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ESNext
})
)(() => ({ ...Vue, resolveComponent: () => stub }), exports)
const render = () =>
renderToString(
Vue.createSSRApp({ setup: () => () => exports.render(Vue.proxyRefs(h.api), []) })
)
const shown = await render()
assert.match(shown, /class="mp-wv-card__no">固定主题<\/div>/)
assert.match(shown, /class="mp-quote-list-card__number">固定单号<\/span>/)
assert.equal(h.api.quotes.value[0].quoteTitle, '固定主题(固定单号)')
for (const value of ['固定主题', '固定单号', '唯一状态']) assert.ok(shown.includes(value))
for (const value of [
'唯一部门',
'唯一更新人',
'刷新',
'清空筛选',
'字段设置',
'列统计',
'多选',
'选择已加载'
])
assert.ok(!shown.includes(value), value)
const filters = parse(
readFileSync(new URL('../src/views/m/crm/quote/QuoteFilters.vue', import.meta.url), 'utf8')
).descriptor
const script = compileScript(filters, { id: 'quote-filters' })
assert.deepEqual(
compileTemplate({
id: 'quote-filters',
filename: 'QuoteFilters.vue',
source: filters.template!.content,
compilerOptions: { bindingMetadata: script.bindings }
}).errors,
[]
)
} finally {
h.stop()
}
})
test('approval list requires explicit backend permission before displaying approve and reject actions', async () => {
const h = await harness('MpQuoteList', { mode: 'approve' })
try {
for (const flag of [false, undefined, true]) {
h.api.quotes.value = [{ id: '1', status: 'submitted', canApprove: flag }]
const actions = h.api.cardActions(h.api.cards.value[0]).map((a: any) => a.key)
assert.equal(actions.includes('approve'), flag === true)
assert.equal(actions.includes('reject'), flag === true)
assert.ok(actions.includes('view'))
}
} finally {
h.stop()
}
})
test('quote filter pickers preserve drafts on cancel and serialize confirmed enum, number and date values', async () => {
const props = Vue.reactive({
modelValue: true,
query: { quoteTypeIn: 'sales' },
context: {},
visibleColumns: ['quoteType', 'finalAmount', 'quoteDate']
})
const h = await harness('QuoteFilters', props)
try {
h.api.openEnum('quoteType')
h.api.toggleEnum('purchase')
h.api.enumOpen.value = false
assert.deepEqual(h.api.draft.value.quoteType, ['sales'])
h.api.openEnum('quoteType')
assert.deepEqual(h.api.enumDraft.value, ['sales'])
h.api.toggleEnum('purchase')
h.api.confirmEnum()
assert.equal(h.api.enumSummary('quoteType'), '销售、采购')
h.api.openSingle('numberOp', 'finalAmount')
h.api.confirmSingle({ selectedValues: ['gte'] })
h.api.draft.value.finalAmount.value = '100'
h.api.openDate('quoteDate', 'from')
h.api.confirmDate({ selectedValues: ['2026', '10', '24'] })
h.api.apply()
assert.deepEqual(h.events.find((e: any) => e[0] === 'apply')[1], {
quoteTypeIn: 'sales,purchase',
finalAmountOp: 'gte',
finalAmount: '100',
quoteDateBegin: '2026-10-24'
})
h.api.openEnum('quoteType')
h.api.openSingle('sortField')
h.api.openDate('quoteDate', '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()
}
})