| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475 |
- 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()
- }
- })
|