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