476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
|
|
async function model() {
|
|
assert.ok(
|
|
existsSync(new URL('../src/views/m/crm/product/listState.ts', import.meta.url)),
|
|
'mobile product list state must exist'
|
|
)
|
|
return import('../src/views/m/crm/product/listState')
|
|
}
|
|
test('enum selections and all numeric operators serialize to Web queries; invalid ranges are rejected', async () => {
|
|
const m = await model()
|
|
const draft = m.createProductFilterDraft({ productNoIn: 'P1,P2', priceOp: 'gte', price: 0 })
|
|
assert.deepEqual(m.productFilterQuery(draft), {
|
|
productNoIn: 'P1,P2',
|
|
priceOp: 'gte',
|
|
price: '0'
|
|
})
|
|
for (const op of ['eq', 'gt', 'gte', 'lt', 'lte'] as const) {
|
|
draft.cost = { op, value: '12.5', min: '', max: '' }
|
|
assert.equal(m.productFilterQuery(draft).costOp, op)
|
|
assert.equal(m.productFilterQuery(draft).cost, '12.5')
|
|
}
|
|
draft.cost = { op: 'between', value: '', min: '20', max: '10' }
|
|
assert.throws(() => m.productFilterQuery(draft), /最低值不能大于最高值/)
|
|
})
|
|
test('hidden columns drop related filters, reset keeps keyword, and facets exclude self and pagination', async () => {
|
|
const m = await model()
|
|
const query = {
|
|
keyword: '酸',
|
|
categoryId: 'C',
|
|
categoryNameIn: '化工',
|
|
model: '快速',
|
|
modelIn: '精确',
|
|
priceOp: 'gte',
|
|
price: 0,
|
|
productNameIn: '产品',
|
|
statusIn: 'off'
|
|
}
|
|
assert.deepEqual(m.cleanProductQuery(query, []), {
|
|
keyword: '酸',
|
|
productNameIn: '产品',
|
|
statusIn: 'off'
|
|
})
|
|
assert.deepEqual(m.cleanProductQuery(query, [], true), { keyword: '酸', statusIn: 'off' })
|
|
assert.deepEqual(
|
|
m.buildMobileProductQuery({
|
|
query,
|
|
keyword: '酸',
|
|
categoryId: 'C',
|
|
model: '快速',
|
|
visible: m.productColumnDefs.map((d) => d.prop),
|
|
page: 3,
|
|
pageSize: 20
|
|
}),
|
|
{
|
|
keyword: '酸',
|
|
categoryNameIn: '化工',
|
|
modelIn: '精确',
|
|
productNameIn: '产品',
|
|
statusIn: 'on',
|
|
page: 3,
|
|
pageSize: 20
|
|
}
|
|
)
|
|
const facet = m.productFacetQuery({ ...query, page: 2, pageSize: 20 }, 'categoryName')
|
|
assert.equal(facet.categoryId, undefined)
|
|
assert.equal(facet.categoryNameIn, undefined)
|
|
assert.equal(facet.page, undefined)
|
|
assert.equal(facet.keyword, '酸')
|
|
assert.equal(facet.limit, 500)
|
|
})
|
|
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import * as Vue from 'vue'
|
|
import ts from 'typescript'
|
|
import { buildProductCard } from '../src/utils/mp-crm-product-card'
|
|
async function harness(page: string, props: any = {}, overrides: Record<string, any> = {}) {
|
|
const path = new URL(`../src/views/m/crm/product/${page}.vue`, import.meta.url)
|
|
assert.ok(existsSync(path), `${page} must exist`)
|
|
const source = readFileSync(path, '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[] = [],
|
|
statuses: any[] = [],
|
|
deletes: any[] = [],
|
|
events: any[] = [],
|
|
facets: any[] = []
|
|
const user = Vue.reactive({ accessToken: 'token1', getUserInfo: { id: 'u1' } })
|
|
let facetFail = false
|
|
Object.assign(bindings, await model(), {
|
|
buildProductCard,
|
|
defineProps: () => props,
|
|
defineEmits:
|
|
() =>
|
|
(...args: any[]) =>
|
|
events.push(args),
|
|
useRouter: () => ({ push: () => {} }),
|
|
useUserStore: () => user,
|
|
useListReturnState: () => ({ restoring: Vue.ref(false), restore: async () => {} }),
|
|
useIntersectionObserver: () => {},
|
|
useDebounceFn: (fn: any) => fn,
|
|
canDeleteByCreator: (row: any) => row.createBy === user.getUserInfo.id,
|
|
fetchCategoryTree: async () => [],
|
|
buildPathLabelMapFromTreeRoots: () => new Map(),
|
|
fetchProductList: async (q: any) => {
|
|
requests.push(q)
|
|
return { list: [], total: 0 }
|
|
},
|
|
fetchProductColumnFacetValues: async (q: any) => {
|
|
facets.push(q)
|
|
if (facetFail) throw new Error('离线')
|
|
return {
|
|
list: [
|
|
{ value: 'A', label: '甲' },
|
|
{ value: 'B', label: '乙' }
|
|
]
|
|
}
|
|
},
|
|
showProductConfirmDialog: async () => {},
|
|
fetchUpdateProductStatus: async (q: any) => statuses.push(q),
|
|
fetchDeleteProduct: async (q: any) => deletes.push(q),
|
|
HttpError: class extends Error {},
|
|
ApiStatus: { forbidden: 403 },
|
|
onMounted: () => {},
|
|
onBeforeUnmount: () => {}
|
|
})
|
|
Object.assign(bindings, overrides)
|
|
for (const k of Object.keys(bindings))
|
|
if (!/^[a-zA-Z_$][\w$]*$/.test(k) || k === 'default') delete bindings[k]
|
|
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,
|
|
statuses,
|
|
deletes,
|
|
events,
|
|
facets,
|
|
user,
|
|
failFacet: (v: boolean) => {
|
|
facetFail = v
|
|
},
|
|
stop: () => scope.stop()
|
|
}
|
|
}
|
|
test('fixed product columns retain ordinary filters and discard hidden legacy conditions', async () => {
|
|
const h = await harness('index')
|
|
try {
|
|
h.api.products.value = [{ id: '1', productName: '产品', createBy: 'u1', cost: 10 }]
|
|
assert.equal(h.api.cards.value[0].previewFields.length, 6)
|
|
assert.equal(h.api.cards.value[0].extraFields.length, 0)
|
|
h.api.query.value = { productNameIn: 'P', costOp: 'gt', cost: 1 }
|
|
h.api.keyword.value = '酸'
|
|
await h.api.fetchPage(true)
|
|
assert.equal(h.requests.at(-1).productNameIn, 'P')
|
|
assert.equal(h.requests.at(-1).keyword, '酸')
|
|
assert.equal(h.requests.at(-1).cost, undefined)
|
|
h.user.accessToken = 'token2'
|
|
assert.deepEqual(h.api.products.value, [])
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
test('single product status change guards duplicate submission without a delete entry', async () => {
|
|
const h = await harness('index')
|
|
try {
|
|
assert.equal(h.api.deleteCard, undefined)
|
|
assert.equal(h.deletes.length, 0)
|
|
const card = { id: '1', createBy: 'u1', status: 'on' }
|
|
await Promise.all([h.api.toggleStatus(card), h.api.toggleStatus(card)])
|
|
assert.deepEqual(h.statuses, [{ ids: ['1'], status: 'off' }])
|
|
assert.deepEqual(h.deletes, [])
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
test('facet uses applied context, preserves selections, searches locally and retries failure', async () => {
|
|
const props = Vue.reactive({
|
|
modelValue: true,
|
|
query: { brandsIn: 'A' },
|
|
context: { keyword: '酸', brandsIn: 'A', modelIn: 'M', page: 2 },
|
|
visibleColumns: ['brands']
|
|
})
|
|
const h = await harness('ProductFilters', props)
|
|
try {
|
|
h.failFacet(true)
|
|
await h.api.loadFacet('brands')
|
|
assert.match(h.api.facetErrors.brands, /离线/)
|
|
h.failFacet(false)
|
|
await h.api.loadFacet('brands')
|
|
assert.equal(h.api.facetErrors.brands, '')
|
|
assert.deepEqual(h.facets.at(-1), { keyword: '酸', modelIn: 'M', field: 'brands', limit: 500 })
|
|
h.api.searches.brands = '甲'
|
|
assert.deepEqual(
|
|
h.api.optionsFor('brands').map((x: any) => x.value),
|
|
['A']
|
|
)
|
|
assert.deepEqual(h.api.draft.value.brands, ['A'])
|
|
props.visibleColumns = []
|
|
await Vue.nextTick()
|
|
assert.deepEqual(h.api.draft.value.brands, [])
|
|
h.api.draft.value.price = { op: 'between', min: '20', max: '10', value: '' }
|
|
props.visibleColumns = ['price']
|
|
await Vue.nextTick()
|
|
h.api.apply()
|
|
assert.equal(h.events.length, 0, 'invalid range must not emit query')
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('selected filter options remain visible during search and can be removed before applying', async () => {
|
|
const props = Vue.reactive({
|
|
modelValue: true,
|
|
query: { brandsIn: 'A,B' },
|
|
context: {},
|
|
visibleColumns: ['brands', 'price']
|
|
})
|
|
const h = await harness('ProductFilters', props)
|
|
try {
|
|
await h.api.loadFacet('brands')
|
|
h.api.searches.brands = '甲'
|
|
assert.deepEqual(h.api.optionsFor('brands'), [{ value: 'A', label: '甲' }])
|
|
assert.deepEqual(h.api.selectedOptionsFor('brands'), [
|
|
{ value: 'A', label: '甲' },
|
|
{ value: 'B', label: '乙' }
|
|
])
|
|
h.api.openEnum('brands')
|
|
h.api.toggleEnum('B')
|
|
assert.deepEqual(h.api.draft.value.brands, ['A', 'B'], 'picker changes remain a draft')
|
|
h.api.enumOpen.value = false
|
|
h.api.openEnum('brands')
|
|
assert.deepEqual(h.api.enumDraft.value, ['A', 'B'], 'cancel preserves the original selection')
|
|
h.api.toggleEnum('B')
|
|
h.api.confirmEnum()
|
|
assert.deepEqual(h.api.draft.value.brands, ['A'])
|
|
assert.equal(props.query.brandsIn, 'A,B')
|
|
assert.deepEqual(h.events, [])
|
|
assert.ok(h.api.numberDefs.value.every((d: any) => d.prop !== 'price'))
|
|
h.api.apply()
|
|
assert.deepEqual(h.events[0], ['apply', { brandsIn: 'A' }])
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('active number filters preserve Web string values and explicit between operator', async () => {
|
|
const m = await model()
|
|
const draft = m.createProductFilterDraft()
|
|
draft.price = { op: 'between', value: '', min: '0', max: '10.5' }
|
|
draft.cost = { op: 'eq', value: '12.50', min: '', max: '' }
|
|
assert.deepEqual(m.productFilterQuery(draft), {
|
|
priceOp: 'between',
|
|
priceMin: '0',
|
|
priceMax: '10.5',
|
|
costOp: 'eq',
|
|
cost: '12.50'
|
|
})
|
|
})
|
|
|
|
test('product list ignores obsolete saved fields and keeps seven fixed default columns', async () => {
|
|
const reads: string[] = []
|
|
Object.assign(globalThis, {
|
|
localStorage: {
|
|
getItem: (key: string) => {
|
|
reads.push(key)
|
|
return '[]'
|
|
},
|
|
setItem: () => {}
|
|
}
|
|
})
|
|
const h = await harness('index')
|
|
try {
|
|
assert.equal(h.api.visibleColumns.value.length, 7)
|
|
assert.equal(h.api.visibleColumns.value.includes('cost'), false)
|
|
assert.deepEqual(reads, [])
|
|
for (const name of ['selectedIds', 'selecting', 'columnsOpen', 'selectLoaded', 'batchStatus'])
|
|
assert.equal(h.api[name], undefined, `${name} must not remain in mobile list`)
|
|
} finally {
|
|
h.stop()
|
|
delete (globalThis as any).localStorage
|
|
}
|
|
})
|
|
test('actual product list renders fixed default data and excludes desktop management controls', async () => {
|
|
const { compileTemplate, compileScript } = await import('@vue/compiler-sfc')
|
|
const { renderToString } = await import('vue/server-renderer')
|
|
const h = await harness('index')
|
|
try {
|
|
h.api.products.value = [
|
|
{
|
|
id: '1',
|
|
productName: '固定产品名称',
|
|
productNo: '编号值',
|
|
status: 'on',
|
|
price: 19,
|
|
cost: 15
|
|
}
|
|
]
|
|
h.api.loading.value = false
|
|
const source = readFileSync(
|
|
new URL('../src/views/m/crm/product/index.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
const descriptor = parse(source).descriptor
|
|
compileScript(descriptor, { id: 'list' })
|
|
const template = compileTemplate({
|
|
id: 'list',
|
|
filename: 'index.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 rendered = await render()
|
|
for (const value of ['固定产品名称', '编号值', '¥19'])
|
|
assert.ok(rendered.includes(value), value)
|
|
for (const value of [
|
|
'¥15',
|
|
'更多字段',
|
|
'字段设置',
|
|
'刷新',
|
|
'清空筛选',
|
|
'多选',
|
|
'列统计',
|
|
'批量'
|
|
])
|
|
assert.ok(!rendered.includes(value), value)
|
|
const filters = parse(
|
|
readFileSync(
|
|
new URL('../src/views/m/crm/product/ProductFilters.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
).descriptor
|
|
const script = compileScript(filters, { id: 'filters' })
|
|
assert.deepEqual(
|
|
compileTemplate({
|
|
id: 'filters',
|
|
filename: 'ProductFilters.vue',
|
|
source: filters.template!.content,
|
|
compilerOptions: { bindingMetadata: script.bindings }
|
|
}).errors,
|
|
[]
|
|
)
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('cancelled confirmation and changed identity never submit pending single-record actions', async () => {
|
|
let resolve!: () => void
|
|
let cancel = true
|
|
const h = await harness(
|
|
'index',
|
|
{},
|
|
{
|
|
showProductConfirmDialog: () =>
|
|
cancel
|
|
? Promise.reject(new Error('cancel'))
|
|
: new Promise<void>((r) => {
|
|
resolve = r
|
|
})
|
|
}
|
|
)
|
|
try {
|
|
const card = { id: '1', createBy: 'u1', status: 'on' }
|
|
await h.api.toggleStatus(card)
|
|
assert.deepEqual(h.deletes, [])
|
|
assert.equal(h.api.actingId.value, '')
|
|
cancel = false
|
|
const pending = h.api.toggleStatus(card)
|
|
h.user.accessToken = 'other'
|
|
resolve()
|
|
await pending
|
|
assert.deepEqual(h.statuses, [])
|
|
assert.equal(h.api.actingId.value, '')
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|
|
|
|
test('closing pending facets resets loading and reopening can fetch without stale completion overwriting options', async () => {
|
|
const props = Vue.reactive({
|
|
modelValue: true,
|
|
query: {},
|
|
context: { statusIn: 'on' },
|
|
visibleColumns: ['brands']
|
|
})
|
|
let resolveOld!: (value: any) => void
|
|
let calls = 0
|
|
const h = await harness('ProductFilters', props, {
|
|
fetchProductColumnFacetValues: () => {
|
|
calls++
|
|
return calls === 1
|
|
? new Promise((resolve) => {
|
|
resolveOld = resolve
|
|
})
|
|
: Promise.resolve({ list: [{ value: 'new', label: '新候选' }] })
|
|
}
|
|
})
|
|
try {
|
|
const pending = h.api.loadFacet('brands')
|
|
assert.equal(h.api.facetLoading.brands, true)
|
|
props.modelValue = false
|
|
await Vue.nextTick()
|
|
assert.equal(h.api.facetLoading.brands, false)
|
|
props.modelValue = true
|
|
await Vue.nextTick()
|
|
h.api.openEnum('brands')
|
|
await Vue.nextTick()
|
|
assert.equal(calls, 2)
|
|
resolveOld({ list: [{ value: 'old', label: '旧候选' }] })
|
|
await pending
|
|
assert.equal(h.api.facetLoading.brands, false)
|
|
assert.deepEqual(
|
|
h.api.optionsFor('brands').map((o: any) => o.value),
|
|
['new']
|
|
)
|
|
} finally {
|
|
h.stop()
|
|
}
|
|
})
|