678 lines
26 KiB
TypeScript
678 lines
26 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { readFileSync, existsSync } from 'node:fs'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
import * as vue from 'vue'
|
|
import { useClientColumnFilters } from '../src/composables/useClientColumnFilters'
|
|
import * as defs from '../src/views/crm/customer/dialogColumnFilterDefs'
|
|
const flush = async () => {
|
|
for (let i = 0; i < 20; i++) await Promise.resolve()
|
|
}
|
|
// Execute actual script-setup functions inside Vue's renderer; API responses alone are deferred.
|
|
const helperPath = new URL('../src/utils/mp-customer-panel-identity.ts', import.meta.url)
|
|
function evaluate(page: string, props: any, overrides: Record<string, any>, events: any[]) {
|
|
const source = parse(
|
|
readFileSync(new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8')
|
|
).descriptor.scriptSetup!.content
|
|
const ast = ts.createSourceFile('panel.ts', source, ts.ScriptTarget.Latest, true)
|
|
const vueEntries = Object.entries(vue).filter(
|
|
([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default'
|
|
)
|
|
const bindings: Record<string, any> = Object.fromEntries(vueEntries)
|
|
delete bindings.default
|
|
for (const statement of ast.statements) {
|
|
if (!ts.isImportDeclaration(statement)) continue
|
|
const clause = statement.importClause
|
|
if (clause?.name) bindings[clause.name.text] = () => undefined
|
|
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
|
|
for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
|
|
}
|
|
if (existsSync(helperPath)) {
|
|
const helper = ts.transpile(
|
|
readFileSync(helperPath, 'utf8').replace('export function', 'function'),
|
|
{ target: ts.ScriptTarget.ESNext }
|
|
)
|
|
bindings.useCustomerPanelIdentity = new Function(
|
|
...vueEntries.map(([k]) => k),
|
|
`${helper};return useCustomerPanelIdentity`
|
|
)(...vueEntries.map(([, v]) => v))
|
|
}
|
|
Object.assign(bindings, {
|
|
useClientColumnFilters,
|
|
...defs,
|
|
OPPORTUNITY_SOURCE_FALLBACK: {},
|
|
OPPORTUNITY_TYPE_FALLBACK: {},
|
|
defineProps: () => props,
|
|
defineEmits:
|
|
() =>
|
|
(...args: any[]) =>
|
|
events.push(args),
|
|
defineExpose() {},
|
|
useRouter: () => ({ push: async () => {} }),
|
|
useDictStore: () => ({ getOptions: () => [], getLabel: () => '', getTagType: () => '' }),
|
|
unwrapRows: (v: any) => (Array.isArray(v) ? v : v?.data?.rows || v?.data || v?.rows || []),
|
|
unwrapData: (v: any) => v?.data || v,
|
|
canDeleteByCreator: () => true,
|
|
contactFields: [{ key: 'contactName', required: true }],
|
|
invoiceFields: [{ key: 'companyName' }],
|
|
requiredFieldsError: () => '',
|
|
contactDetailsError: () => '',
|
|
percentageError: () => '',
|
|
apiTime: (v: any) => v,
|
|
opportunityProgress: () => ({}),
|
|
showCrmConfirmDialog: async () => {},
|
|
fetchContactByCustomerId: async () => [],
|
|
fetchFollowByCustomerId: async () => [],
|
|
getInvoiceInfoByCustomerId: async () => [],
|
|
fetchContactDetail: async (v: any) => v,
|
|
fetchUserOptions: async () => [],
|
|
fetchCustomerOptions: async () => [],
|
|
listOpportunity: async () => [],
|
|
listContract: async () => [],
|
|
listOperLog: async () => [],
|
|
getOpportunity: async (id: string) => ({ id }),
|
|
listFollowByOpportunity: async () => [],
|
|
getStageLogList: async () => [],
|
|
...overrides
|
|
})
|
|
const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
|
|
const names = statements.flatMap((s) =>
|
|
ts.isFunctionDeclaration(s)
|
|
? [s.name!.text]
|
|
: ts.isVariableStatement(s)
|
|
? s.declarationList.declarations.flatMap((d) =>
|
|
ts.isObjectBindingPattern(d.name)
|
|
? d.name.elements.map((e) => e.name.getText(ast))
|
|
: [d.name.getText(ast)]
|
|
)
|
|
: []
|
|
)
|
|
const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
|
|
target: ts.ScriptTarget.ESNext
|
|
})
|
|
return new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
|
|
...Object.values(bindings)
|
|
)
|
|
}
|
|
|
|
function mount(page: string, kind = 'contract', extraProps: any = {}, overrides: any = {}) {
|
|
let api: any
|
|
const props = vue.shallowReactive({ customerId: 'A', kind, ...extraProps })
|
|
const renderer = vue.createRenderer<any, any>({
|
|
patchProp() {},
|
|
insert() {},
|
|
remove() {},
|
|
createElement: () => ({}),
|
|
createText: () => ({}),
|
|
createComment: () => ({}),
|
|
setText() {},
|
|
setElementText() {},
|
|
parentNode: () => null,
|
|
nextSibling: () => null
|
|
})
|
|
const app = renderer.createApp({
|
|
setup() {
|
|
api = evaluate(page, props, overrides, [])
|
|
return () => vue.h('div')
|
|
}
|
|
})
|
|
app.mount({})
|
|
return { api, props, stop: () => app.unmount() }
|
|
}
|
|
|
|
test('联系人姓名手机号组合、枚举并集和快捷筛选共享状态', async () => {
|
|
const { api, stop } = mount('ContactPanel')
|
|
await flush()
|
|
assert.ok(api.filterApi, '面板应使用 Web 客户端筛选 API')
|
|
api.rows.value = [
|
|
{ id: '1', contactName: '张甲', mobile: '138', gender: 'male', role: 'a' },
|
|
{ id: '2', contactName: '张乙', mobile: '139', gender: 'female', role: 'b' },
|
|
{ id: '3', contactName: '李甲', mobile: '138', gender: 'male', role: 'c' }
|
|
]
|
|
api.filterApi.getFilterState('contactName').text = '张'
|
|
api.filterApi.getFilterState('mobile').text = '138'
|
|
assert.deepEqual(
|
|
api.filtered.value.map((r: any) => r.id),
|
|
['1']
|
|
)
|
|
api.resetMore()
|
|
api.filterApi.getFilterState('role').selected = ['a', 'b']
|
|
assert.equal(api.filtered.value.length, 2)
|
|
api.roleFilter.value = 'c'
|
|
assert.deepEqual(
|
|
api.filtered.value.map((r: any) => r.id),
|
|
['3']
|
|
)
|
|
api.resetMore()
|
|
assert.equal(api.roleFilter.value, '')
|
|
assert.equal(api.filtered.value.length, 3)
|
|
stop()
|
|
})
|
|
|
|
test('联系人快速搜索同时匹配角色显示名称和原始编码', async () => {
|
|
const { api, stop } = mount('ContactPanel', 'contract', {}, {
|
|
useDictStore: () => ({
|
|
getOptions: () => [],
|
|
getLabel: (_code: string, value: string) =>
|
|
({ decision: '决策人', manager: '管理者' })[value] || value || ''
|
|
})
|
|
})
|
|
try {
|
|
await flush()
|
|
api.rows.value = [
|
|
{ id: '1', contactName: '张甲', role: 'decision' },
|
|
{ id: '2', contactName: '李乙', role: 'manager' },
|
|
{ id: '3', contactName: '王丙', role: 'legacy' }
|
|
]
|
|
for (const [keyword, expected] of [
|
|
['决策人', ['1']],
|
|
[' 策人 ', ['1']],
|
|
['DECISION', ['1']],
|
|
['管理', ['2']],
|
|
['legacy', ['3']],
|
|
['不存在角色', []],
|
|
['', ['1', '2', '3']]
|
|
] as Array<[string, string[]]>) {
|
|
api.keyword.value = keyword
|
|
assert.deepEqual(api.filtered.value.map((row: any) => row.id), expected, keyword)
|
|
}
|
|
} finally {
|
|
stop()
|
|
}
|
|
})
|
|
|
|
test('联系人角色名称搜索与列筛选取交集,清空不会丢失原筛选', async () => {
|
|
const { api, stop } = mount('ContactPanel', 'contract', {}, {
|
|
useDictStore: () => ({
|
|
getOptions: () => [],
|
|
getLabel: (_code: string, value: string) => value === 'decision' ? '决策人' : value
|
|
})
|
|
})
|
|
try {
|
|
await flush()
|
|
api.rows.value = [
|
|
{ id: '1', role: 'decision', gender: 'male' },
|
|
{ id: '2', role: 'decision', gender: 'female' },
|
|
{ id: '3', role: 'manager', gender: 'female' }
|
|
]
|
|
api.genderFilter.value = 'female'
|
|
api.keyword.value = '决策人'
|
|
assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['2'])
|
|
api.roleFilter.value = 'manager'
|
|
assert.deepEqual(api.filtered.value, [])
|
|
api.keyword.value = ''
|
|
assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['3'])
|
|
} finally {
|
|
stop()
|
|
}
|
|
})
|
|
|
|
test('联系人角色字典后加载时重新计算名称搜索结果', async () => {
|
|
const labels = vue.reactive<Record<string, string>>({})
|
|
const { api, stop } = mount('ContactPanel', 'contract', {}, {
|
|
useDictStore: () => ({
|
|
getOptions: () => [],
|
|
getLabel: (_code: string, value: string) => labels[value] || value || ''
|
|
})
|
|
})
|
|
try {
|
|
await flush()
|
|
api.rows.value = [{ id: '1', role: 'decision' }]
|
|
api.keyword.value = '决策人'
|
|
assert.deepEqual(api.filtered.value, [])
|
|
labels.decision = '决策人'
|
|
assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['1'])
|
|
labels.decision = '决策负责人'
|
|
assert.deepEqual(api.filtered.value, [])
|
|
api.keyword.value = '决策负责人'
|
|
assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['1'])
|
|
} finally {
|
|
stop()
|
|
}
|
|
})
|
|
|
|
test('开票默认多选与单位税号组合即时筛选', async () => {
|
|
const { api, stop } = mount('InvoicePanel')
|
|
await flush()
|
|
assert.ok(api.filterApi)
|
|
api.rows.value = [
|
|
{ id: '1', companyName: '甲', taxNo: '123', isDefault: 1 },
|
|
{ id: '2', companyName: '甲', taxNo: '234', isDefault: 0 },
|
|
{ id: '3', companyName: '乙', taxNo: '', isDefault: 0 }
|
|
]
|
|
api.filterApi.getFilterState('isDefault').selected = ['1', '0']
|
|
assert.equal(api.filtered.value.length, 3)
|
|
api.filterApi.getFilterState('companyName').text = '甲'
|
|
api.filterApi.getFilterState('taxNo').text = '123'
|
|
assert.deepEqual(
|
|
api.filtered.value.map((r: any) => r.id),
|
|
['1']
|
|
)
|
|
api.resetMore()
|
|
assert.equal(api.filtered.value.length, 3)
|
|
stop()
|
|
})
|
|
|
|
for (const [kind, date, number] of [
|
|
['contract', 'signDate', 'amount'],
|
|
['log', 'operTime', 'costTime'],
|
|
['opportunity', 'expectDealDate', '']
|
|
])
|
|
test(`${kind} 日期范围及数值比较与 Web 一致`, async () => {
|
|
const { api, props, stop } = mount('RelatedPanel', kind)
|
|
await flush()
|
|
assert.ok(api.filterApi)
|
|
api.rows.value = [10, 20, 30].map((value, i) => ({
|
|
id: String(i),
|
|
[date]: `2026-10-${21 + i} 23:59:59`,
|
|
[number]: value,
|
|
stage: String(i),
|
|
status: String(i),
|
|
contractType: String(i),
|
|
businessType: String(i)
|
|
}))
|
|
Object.assign(api.filterApi.getFilterState(date), { from: '2026-10-22', to: '2026-10-22' })
|
|
assert.deepEqual(
|
|
api.filtered.value.map((r: any) => r.id),
|
|
['1']
|
|
)
|
|
api.resetMore()
|
|
assert.equal(api.filtered.value.length, 3)
|
|
if (number) {
|
|
for (const [op, ids] of [
|
|
['eq', ['1']],
|
|
['gt', ['2']],
|
|
['gte', ['1', '2']],
|
|
['lt', ['0']],
|
|
['lte', ['0', '1']],
|
|
['between', ['1']]
|
|
]) {
|
|
Object.assign(api.filterApi.getFilterState(number), {
|
|
op,
|
|
value: '20',
|
|
min: '15',
|
|
max: '25'
|
|
})
|
|
assert.deepEqual(
|
|
api.filtered.value.map((r: any) => r.id),
|
|
ids
|
|
)
|
|
}
|
|
}
|
|
api.resetMore()
|
|
const prop = kind === 'contract' ? 'contractType' : kind === 'log' ? 'businessType' : 'stage'
|
|
api.filterApi.getFilterState(prop).selected = ['0', '2']
|
|
assert.deepEqual(
|
|
api.filtered.value.map((r: any) => r.id),
|
|
['0', '2']
|
|
)
|
|
api.quick1.value = '1'
|
|
assert.deepEqual(
|
|
api.filtered.value.map((r: any) => r.id),
|
|
['1']
|
|
)
|
|
api.resetMore()
|
|
api.rows.value = []
|
|
assert.equal(api.filtered.value.length, 0, '刷新集合为空时不保留子组件缓存行')
|
|
props.customerId = 'B'
|
|
await vue.nextTick()
|
|
assert.equal(api.filtered.value.length, 0)
|
|
if (kind === 'opportunity') {
|
|
assert.equal(api.listFields.value.filter((f: any) => f.key === 'createTime').length, 1)
|
|
assert.equal(api.detailFields.value.filter((f: any) => f.key === 'createTime').length, 1)
|
|
}
|
|
stop()
|
|
})
|
|
|
|
test('移动筛选控件使用父级状态、枚举搜索/清空和日期/比较器事件即时生效', () => {
|
|
const rows = vue.ref([
|
|
{ gender: 'male', amount: 10, signDate: '2026-10-22' },
|
|
{ gender: 'female', amount: 30, signDate: '2026-10-23' }
|
|
])
|
|
const definitions = [
|
|
...defs.buildDetailContactColumnFilterDefs(() => []),
|
|
...defs.buildDetailContractColumnFilterDefs(() => [])
|
|
]
|
|
const filterApi = useClientColumnFilters(rows, definitions)
|
|
const { api, stop } = mount('LocalFilters', '', { filterApi, definitions, rows: rows.value })
|
|
api.toggleEnum('gender', 'male')
|
|
assert.equal(filterApi.filteredRows.value.length, 1)
|
|
api.enumKeywords.gender = '女'
|
|
assert.deepEqual(
|
|
api.enumOptions(definitions.find((d: any) => d.prop === 'gender')).map((o: any) => o.value),
|
|
['female']
|
|
)
|
|
api.toggleEnum('gender', 'female')
|
|
assert.equal(filterApi.filteredRows.value.length, 2)
|
|
api.clearField('gender')
|
|
assert.equal(filterApi.hasActiveFilters.value, false)
|
|
api.openDate('signDate', 'from')
|
|
api.dateParts.value = ['2026', '10', '23']
|
|
api.onDateConfirm()
|
|
assert.equal(filterApi.filteredRows.value.length, 1)
|
|
api.clearField('signDate')
|
|
assert.equal(filterApi.filteredRows.value.length, 2)
|
|
api.openOp('amount')
|
|
api.onOpConfirm({ selectedValues: ['gte'] })
|
|
api.setRangeValue('amount', 'value', '30')
|
|
assert.equal(filterApi.filteredRows.value.length, 1)
|
|
api.clearAllFilters()
|
|
assert.equal(filterApi.filteredRows.value.length, 2)
|
|
stop()
|
|
})
|
|
|
|
for (const [page, kind, builder] of [
|
|
['ContactPanel', '', defs.buildDetailContactColumnFilterDefs],
|
|
['InvoicePanel', '', defs.buildInvoiceInfoColumnFilterDefs],
|
|
['RelatedPanel', 'opportunity', defs.buildDetailOpportunityColumnFilterDefs],
|
|
['RelatedPanel', 'contract', defs.buildDetailContractColumnFilterDefs],
|
|
['RelatedPanel', 'log', defs.buildDetailOperLogColumnFilterDefs]
|
|
] as const) {
|
|
test(`${page}/${kind}: 每个 Web 字段均绑定真实筛选状态,刷新立即移除旧行`, async () => {
|
|
const { api, stop } = mount(page, kind)
|
|
await flush()
|
|
for (const def of builder(() => [])) {
|
|
api.resetMore()
|
|
assert.equal(api.filterApi.getColumnDef(def.prop)?.type, def.type)
|
|
api.rows.value = [
|
|
{
|
|
id: 'a',
|
|
[def.prop]:
|
|
def.type === 'datetime' ? '2026-10-22 00:00:00' : def.type === 'number' ? 10 : 'alpha'
|
|
},
|
|
{
|
|
id: 'b',
|
|
[def.prop]:
|
|
def.type === 'datetime' ? '2026-10-23 23:59:59' : def.type === 'number' ? 20 : 'beta'
|
|
}
|
|
]
|
|
const state = api.filterApi.getFilterState(def.prop)
|
|
if (def.type === 'text') state.text = 'alpha'
|
|
else if (def.type === 'enum') state.selected = ['alpha']
|
|
else if (def.type === 'datetime')
|
|
Object.assign(state, { from: '2026-10-22', to: '2026-10-22' })
|
|
else Object.assign(state, { op: 'eq', value: '10' })
|
|
assert.deepEqual(
|
|
api.filtered.value.map((r: any) => r.id),
|
|
['a'],
|
|
def.prop
|
|
)
|
|
api.filterApi.clearFilter(def.prop)
|
|
assert.equal(api.filtered.value.length, 2, `${def.prop} 清空`)
|
|
}
|
|
const pending = api.load()
|
|
assert.equal(api.filtered.value.length, 0, '刷新请求未完成时已移除旧行')
|
|
await pending
|
|
assert.equal(api.filtered.value.length, 0)
|
|
stop()
|
|
})
|
|
}
|
|
|
|
// Execute the installed Vant option handler, including its same-value no-emit guard.
|
|
function clickVantOption(modelValue: unknown, value: string, update: (value: string) => void) {
|
|
const source = readFileSync(
|
|
new URL('../node_modules/vant/es/dropdown-item/DropdownItem.mjs', import.meta.url),
|
|
'utf8'
|
|
)
|
|
const ast = ts.createSourceFile('DropdownItem.mjs', source, ts.ScriptTarget.Latest, true)
|
|
let handler = ''
|
|
function visit(node: ts.Node) {
|
|
if (ts.isVariableDeclaration(node) && node.name.getText(ast) === 'onClick' && node.initializer)
|
|
handler = node.initializer.getText(ast)
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(ast)
|
|
assert.ok(handler, '读取真实 Vant option onClick')
|
|
const click = new Function('disabled', 'state', 'option', 'props', 'emit', `return (${handler})`)(
|
|
false,
|
|
{ showPopup: true },
|
|
{ value },
|
|
{ modelValue },
|
|
(event: string, next: string) => {
|
|
if (event === 'update:modelValue') update(next)
|
|
}
|
|
)
|
|
click()
|
|
}
|
|
|
|
for (const [page, kind, prop, quick] of [
|
|
['ContactPanel', '', 'role', 'roleFilter'],
|
|
['ContactPanel', '', 'gender', 'genderFilter'],
|
|
['InvoicePanel', '', 'isDefault', 'defaultFilter'],
|
|
['RelatedPanel', 'opportunity', 'stage', 'quick1'],
|
|
['RelatedPanel', 'opportunity', 'status', 'quick2'],
|
|
['RelatedPanel', 'contract', 'contractType', 'quick1'],
|
|
['RelatedPanel', 'contract', 'status', 'quick2'],
|
|
['RelatedPanel', 'log', 'businessType', 'quick1'],
|
|
['RelatedPanel', 'log', 'status', 'quick2']
|
|
])
|
|
test(`${page}/${kind}/${prop}: 多选后点击 Vant 全部确实清空`, async () => {
|
|
const { api, stop } = mount(page, kind)
|
|
await flush()
|
|
api.rows.value = ['a', 'b', 'c'].map((value) => ({ id: value, [prop]: value }))
|
|
api.filterApi.getFilterState(prop).selected = ['a', 'b']
|
|
assert.equal(api.filtered.value.length, 2)
|
|
clickVantOption(api[quick].value, '', (value) => {
|
|
api[quick].value = value
|
|
})
|
|
assert.equal(api.filtered.value.length, 3)
|
|
assert.equal(api.filterApi.isFilterActive(prop), false)
|
|
stop()
|
|
})
|
|
|
|
test('日期筛选年份可输入、窗口有界、旧年份可达且确认直接生效', async () => {
|
|
const definitions = defs.buildDetailContractColumnFilterDefs(() => [])
|
|
const rows = vue.ref([{ signDate: '1800-02-28' }, { signDate: '2400-02-29' }])
|
|
const filterApi = useClientColumnFilters(rows, definitions)
|
|
const { api, stop } = mount('LocalFilters', '', { filterApi, definitions })
|
|
api.openDate('signDate', 'from')
|
|
assert.ok(api.yearInput, '日期筛选应支持输入年份')
|
|
api.dateParts.value = ['2024', '02', '29']
|
|
await vue.nextTick()
|
|
api.yearInput.value = '1800'
|
|
api.onDateConfirm()
|
|
assert.equal((filterApi.getFilterState('signDate') as any).from, '1800-02-28')
|
|
assert.equal(filterApi.filteredRows.value.length, 2)
|
|
api.openDate('signDate', 'from')
|
|
assert.equal(api.pickerYear.value, 1800)
|
|
assert.equal(api.maxDate.value.getFullYear() - api.minDate.value.getFullYear(), 20)
|
|
api.yearInput.value = '2400'
|
|
api.onDateConfirm()
|
|
assert.equal(filterApi.filteredRows.value.length, 1)
|
|
api.openDate('signDate', 'to')
|
|
api.yearInput.value = '9999'
|
|
api.applyYear()
|
|
assert.equal(api.maxDate.value.getFullYear(), 9999)
|
|
api.yearInput.value = '1000'
|
|
api.applyYear()
|
|
assert.equal(api.minDate.value.getFullYear(), 1000)
|
|
stop()
|
|
})
|
|
|
|
|
|
test('关联商机直接按ID打开,目标切换和卸载丢弃迟到响应', async () => {
|
|
const pending: Record<string, (value: any) => void> = {}
|
|
const { api, props, stop } = mount('RelatedPanel', 'opportunity', { targetOpportunityId: 'O1', readonly: true }, {
|
|
getOpportunity: (id: string) => new Promise((resolve) => { pending[id] = resolve })
|
|
})
|
|
await flush()
|
|
assert.equal(api.detailOpen.value, true)
|
|
assert.ok(pending.O1, '无需列表命中即按ID读取')
|
|
props.targetOpportunityId = 'O2'
|
|
await flush()
|
|
pending.O1({ id: 'O1' })
|
|
await flush()
|
|
assert.notEqual(api.detail.value?.id, 'O1')
|
|
pending.O2({ id: 'O2' })
|
|
await flush()
|
|
assert.equal(api.detail.value.id, 'O2')
|
|
props.targetOpportunityId = 'O3'
|
|
await flush()
|
|
stop()
|
|
pending.O3({ id: 'O3' })
|
|
await flush()
|
|
assert.equal(api.detailOpen.value, false)
|
|
assert.equal(api.detail.value, undefined)
|
|
})
|
|
|
|
test('关闭关联商机取消读取,重新打开同ID可以重试', async () => {
|
|
let resolve!: (value: any) => void
|
|
const { api, props, stop } = mount('RelatedPanel', 'opportunity', { targetOpportunityId: 'O1', readonly: true }, {
|
|
getOpportunity: () => new Promise((r) => { resolve = r })
|
|
})
|
|
await flush()
|
|
assert.equal(api.detailOpen.value, true)
|
|
api.closeDetail()
|
|
resolve({ id: 'O1' })
|
|
await flush()
|
|
assert.equal(api.detail.value, undefined)
|
|
props.targetOpportunityId = ''
|
|
await flush()
|
|
props.targetOpportunityId = 'O1'
|
|
await flush()
|
|
resolve({ id: 'O1' })
|
|
await flush()
|
|
assert.equal(api.detail.value.id, 'O1')
|
|
stop()
|
|
})
|
|
|
|
function rangeHarness(kind = 'contract') {
|
|
const parent = mount('RelatedPanel', kind)
|
|
const messages: string[] = []
|
|
const child = mount('LocalFilters', '', {
|
|
definitions: parent.api.filterDefinitions.value,
|
|
filterApi: parent.api.filterApi
|
|
}, { showFailToast: (message: string) => messages.push(message) })
|
|
if (parent.api.localFilters) parent.api.localFilters.value = child.api
|
|
const edit = (prop: string, field: string, value: string) => {
|
|
if (child.api.setRangeValue) child.api.setRangeValue(prop, field, value)
|
|
else child.api.state(prop)[field] = value
|
|
}
|
|
return { parent, child, edit, messages, stop: () => { child.stop(); parent.stop() } }
|
|
}
|
|
|
|
test('关联金额倒置草稿不污染有效筛选,确定保留面板并提示,修正后可提交', async () => {
|
|
const h = rangeHarness()
|
|
try {
|
|
await flush()
|
|
const number = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number')
|
|
const prop = number.prop
|
|
h.parent.api.rows.value = [{ id: 'a', [prop]: 100 }, { id: 'b', [prop]: 1000 }]
|
|
h.edit(prop, 'min', '200')
|
|
assert.deepEqual(h.parent.api.filtered.value.map((r: any) => r.id), ['b'])
|
|
h.edit(prop, 'max', '100')
|
|
assert.deepEqual(h.parent.api.filtered.value.map((r: any) => r.id), ['b'])
|
|
assert.equal(h.child.api.state(prop).max, '100')
|
|
h.parent.api.moreOpen.value = true
|
|
h.parent.api.applyMore()
|
|
assert.equal(h.parent.api.moreOpen.value, true)
|
|
assert.deepEqual(h.messages, [`${number.label}:最小值不能大于最大值`])
|
|
h.edit(prop, 'max', '1000')
|
|
assert.equal(h.child.api.rangeError.value, '')
|
|
h.parent.api.applyMore()
|
|
assert.equal(h.parent.api.moreOpen.value, false)
|
|
} finally { h.stop() }
|
|
})
|
|
|
|
test('所有关联日期字段拒绝倒置草稿,保留已生效日期且可修正', async () => {
|
|
for (const kind of ['contract', 'opportunity', 'log']) {
|
|
const h = rangeHarness(kind)
|
|
try {
|
|
await flush()
|
|
for (const def of h.parent.api.filterDefinitions.value.filter((d: any) => d.type === 'datetime')) {
|
|
h.child.api.clearAllFilters()
|
|
h.edit(def.prop, 'from', '2025-01-01')
|
|
h.edit(def.prop, 'to', '2024-01-01')
|
|
assert.equal(h.parent.api.filterApi.getFilterState(def.prop).to, '', `${kind}/${def.prop}`)
|
|
h.parent.api.moreOpen.value = true
|
|
h.parent.api.applyMore()
|
|
assert.equal(h.parent.api.moreOpen.value, true)
|
|
assert.equal(h.messages.at(-1), `${def.label}:开始日期不能晚于结束日期`)
|
|
h.edit(def.prop, 'to', '2025-01-01')
|
|
assert.equal(h.child.api.rangeError.value, '')
|
|
h.parent.api.applyMore()
|
|
assert.equal(h.parent.api.moreOpen.value, false)
|
|
}
|
|
} finally { h.stop() }
|
|
}
|
|
})
|
|
|
|
test('区间清空本项、全部清空及父面板重置同时清理错误草稿与生效条件', async () => {
|
|
const h = rangeHarness()
|
|
try {
|
|
await flush()
|
|
const prop = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number').prop
|
|
for (const reset of [() => h.child.api.clearField(prop), () => h.child.api.clearAllFilters(), () => h.parent.api.resetMore()]) {
|
|
h.edit(prop, 'min', '200')
|
|
h.edit(prop, 'max', '100')
|
|
reset()
|
|
assert.equal(h.child.api.state(prop).min, '')
|
|
assert.equal(h.child.api.state(prop).max, '')
|
|
assert.equal(h.child.api.rangeError?.value || '', '')
|
|
assert.equal(h.parent.api.filterApi.hasActiveFilters.value, false)
|
|
}
|
|
} finally { h.stop() }
|
|
})
|
|
|
|
test('区间合法零值、负数、小数、单边、相等及切换比较方式继续即时生效', async () => {
|
|
const h = rangeHarness()
|
|
try {
|
|
await flush()
|
|
const prop = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number').prop
|
|
for (const [min, max] of [['', ''], ['0', ''], ['', '0'], ['0', '0'], ['-2.5', '-1.5'], ['44.99', '44.99']]) {
|
|
h.child.api.clearAllFilters()
|
|
h.edit(prop, 'min', min)
|
|
h.edit(prop, 'max', max)
|
|
assert.equal(h.parent.api.filterApi.getFilterState(prop).min, min)
|
|
assert.equal(h.parent.api.filterApi.getFilterState(prop).max, max)
|
|
}
|
|
for (const op of ['eq', 'gt', 'gte', 'lt', 'lte']) {
|
|
h.child.api.clearAllFilters()
|
|
h.edit(prop, 'min', '200')
|
|
h.edit(prop, 'max', '100')
|
|
h.child.api.openOp(prop)
|
|
h.child.api.onOpConfirm({ selectedValues: [op] })
|
|
h.edit(prop, 'value', '44.99')
|
|
assert.equal(h.child.api.validate(), true)
|
|
assert.equal(h.parent.api.filterApi.getFilterState(prop).op, op)
|
|
assert.equal(h.parent.api.filterApi.getFilterState(prop).value, '44.99')
|
|
h.child.api.onOpConfirm({ selectedValues: ['between'] })
|
|
assert.equal(h.child.api.validate(), false)
|
|
assert.equal(h.parent.api.filterApi.getFilterState(prop).op, op)
|
|
}
|
|
} finally { h.stop() }
|
|
})
|
|
|
|
for (const page of ['ContactPanel', 'InvoicePanel', 'RelatedPanel']) {
|
|
test(`${page}: 重置清除所有枚举搜索词并恢复选项,未挂载筛选器时仍能清除条件`, async () => {
|
|
const parent = mount(page)
|
|
await flush()
|
|
const definitions = vue.unref(parent.api.filterDefinitions)
|
|
const child = mount('LocalFilters', '', { definitions, filterApi: parent.api.filterApi })
|
|
try {
|
|
if (parent.api.localFilters) parent.api.localFilters.value = child.api
|
|
const enums = definitions.filter((d: any) => d.type === 'enum')
|
|
assert.ok(enums.length)
|
|
for (const def of enums) {
|
|
child.api.enumKeywords[def.prop] = '无此选项'
|
|
child.api.state(def.prop).selected = ['sample']
|
|
}
|
|
parent.api.resetMore()
|
|
for (const def of enums) {
|
|
assert.equal(child.api.enumKeywords[def.prop] || '', '', `${def.prop}搜索词应清空`)
|
|
assert.deepEqual(child.api.state(def.prop).selected, [])
|
|
assert.deepEqual(child.api.enumOptions(def), def.enumOptions || parent.api.filterApi.collectUniqueOptions(def.prop))
|
|
}
|
|
// 没有选中条件时再次搜索,组件内“清空筛选”也必须恢复完整选项。
|
|
child.api.enumKeywords[enums[0].prop] = '不存在'
|
|
child.api.clearAllFilters()
|
|
assert.equal(child.api.enumKeywords[enums[0].prop] || '', '')
|
|
parent.api.localFilters.value = undefined
|
|
parent.api.filterApi.getFilterState(enums[0].prop).selected = ['sample']
|
|
parent.api.resetMore()
|
|
assert.equal(parent.api.filterApi.hasActiveFilters.value, false)
|
|
} finally { child.stop(); parent.stop() }
|
|
})
|
|
}
|