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