import { buildMobileContractQuery, cleanContractQuery } from '../src/views/m/crm/contract/listState' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { compile, computed, createRenderer, defineComponent, h, nextTick, reactive, ref } from 'vue' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import { buildMobileQuoteQuery, cleanQuoteQuery, quoteColumnDefs } from '../src/views/m/crm/quote/listState' import { buildMobileProductQuery, cleanProductQuery, productColumnDefs } from '../src/views/m/crm/product/listState' import { cleanCustomerColumnQuery, customerColumnDefs } from '../src/views/m/crm/customer/columns' const pages = [ 'customer/index.vue', 'product/index.vue', 'quote/MpQuoteList.vue', 'contract/MpContractList.vue' ] const root = new URL('../src/views/m/crm/', import.meta.url) // Vue's real compiler and renderer run against in-memory host nodes. No browser is launched. function mountTemplate(file: string, mode = 'manage') { type Node = { type: string; children: Node[]; parent?: Node; props: Record } const node = (type: string): Node => ({ type, children: [], props: {}, style: {}, addEventListener() {}, removeEventListener() {} }) as Node const renderer = createRenderer({ createElement: node, createText: node, createComment: node, setText() {}, setElementText() {}, parentNode: (el) => el.parent!, nextSibling: (el) => el.parent?.children[el.parent.children.indexOf(el) + 1] || null, patchProp: (el, key, _old, value) => { el.props[key] = value }, insert(el, parent, anchor) { if (el.parent) el.parent.children.splice(el.parent.children.indexOf(el), 1) el.parent = parent const index = anchor ? parent.children.indexOf(anchor) : -1 parent.children.splice(index < 0 ? parent.children.length : index, 0, el) }, remove(el) { el.parent?.children.splice(el.parent.children.indexOf(el), 1) } }) const state = reactive>({ loading: false, forbidden: false, keyword: '山峡', rows: [], products: [], cards: [], views: [], viewTabs: [], selected: [], selectedIds: [], categoryPathMap: new Map(), selectedRows: [], previewFields: [], filtersOpen: true, moreOpen: true, submitForm: { approverIds: [] }, moreDraft: {}, mode, error: '', errorText: '' }) const fallback = () => [] const context = new Proxy(state, { has: (_, key) => typeof key === 'string' && !key.startsWith('_') && !['String', 'Boolean', 'Number'].includes(key), get: (target, key) => key === Symbol.unscopables ? undefined : key in target ? target[key as string] : fallback }) const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8')) const render = compile(descriptor.template!.content) const passthrough = defineComponent({ setup: (_, { slots }) => () => h( 'stub', Object.values(slots).flatMap((slot) => slot?.()) ) }) const header = defineComponent({ setup: (_, { slots }) => () => h( 'input', Object.values(slots).flatMap((slot) => slot?.()) ) }) const app = renderer.createApp({ render: () => render.call(context, context, []) }) const tags = [...descriptor.template!.content.matchAll(/<([A-Z][\w]+|van-[\w-]+)/g)].map( (match) => match[1] ) for (const tag of new Set(tags)) { const type = ['CustomerFilters', 'ProductFilters', 'QuoteFilters', 'ContractFilters'].includes( tag ) ? 'filters' : tag === 'van-popup' ? 'popup' : tag === 'MpListPageSkeleton' ? 'skeleton' : '' app.component( tag, tag === 'MpBusinessHeader' ? header : type ? defineComponent({ setup: (_, { slots }) => () => h(type, slots.default?.()) }) : passthrough ) } const container = node('root') app.mount(container) const find = (type: string, start = container): Node | undefined => { if (start.type === type) return start for (const child of start.children) { const found = find(type, child) if (found) return found } } return { state, find, app } } for (const { page, mode } of pages.flatMap((page) => (page.includes('Mp') ? ['manage', 'approve'] : ['manage']).map((mode) => ({ page, mode })) )) { test(`${page} (${mode}): search retains the same header and filter nodes while results reload`, async () => { const { state, find, app } = mountTemplate(page, mode) const input = find('input') assert.ok(input) const filterType = ['customer', 'product', 'quote', 'contract'].some((prefix) => page.startsWith(prefix) ) ? 'filters' : 'popup' const filters = find(filterType) assert.ok(filters) state.loading = true await nextTick() assert.ok(find('input') === input, 'refresh must not unmount the search input') assert.ok(find(filterType) === filters, 'open filter contents must stay mounted') assert.ok(find('skeleton'), 'loading still shows a body skeleton') state.loading = false await nextTick() assert.ok(find('input') === input) state.forbidden = true await nextTick() assert.ok(find('input'), '403 keeps navigation available') app.unmount() }) } function sourceFunction( file: string, name: string, bindings: Record, dependencies: string[] = [] ) { const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8')) const source = ts.createSourceFile( file, descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const declarations = source.statements.filter( (item) => ts.isFunctionDeclaration(item) && [name, ...dependencies].includes(item.name?.text || '') ) const code = ts.transpile(declarations.map((item) => item.getText(source)).join('\n'), { target: ts.ScriptTarget.ESNext }) return new Function(...Object.keys(bindings), `${code}; return ${name}`)( ...Object.values(bindings) ) } for (const page of pages) { test(`${page}: shell ready restores the saved query instead of clearing it`, async () => { let restores = 0 const bindings: Record = { props: { mode: 'manage' }, categoryRequestSeq: 0, modelRequestSeq: 0, categoryMapLoaded: false, modelOptionsLoaded: false, reload() {}, clearSelection() {}, syncQuickFilters() {}, previousView: 'all', listReturnState: { restore: async () => { restores++ } } } for (const key of [ 'keyword', 'categoryId', 'model', 'priceMin', 'priceMax', 'view', 'quoteType', 'contractType', 'amountRange', 'quoteDateBegin', 'quoteDateEnd', 'signDateBegin', 'signDateEnd', 'minAmount', 'maxAmount', 'speedOpen', 'selectMode', 'expandedIds', 'selectedIds' ]) { bindings[key] = { value: key === 'keyword' ? '山峡' : '' } } await sourceFunction(page, 'onShellReady', bindings)() assert.equal(bindings.keyword.value, '山峡') assert.equal(restores, 1, 'ready must restore list context after authentication') }) } function returnStateHarness() { let path = '/m/crm/customer' let leave: (to: { path: string }) => void let unmount: () => void const afterEach = new Set<(to: { path: string }, from: { path: string }) => void>() const user = { accessToken: 'session-a', getUserInfo: { userId: 'a', tenantNo: 'tenant-a', roles: ['sales'] } } const scrolls: number[] = [] const bindings = { ref, nextTick, useRoute: () => ({ path }), useRouter: () => ({ afterEach(fn: any) { afterEach.add(fn) return () => afterEach.delete(fn) } }), useUserStore: () => user, onBeforeRouteLeave: (fn: typeof leave) => { leave = fn }, onBeforeUnmount: (fn: typeof unmount) => { unmount = fn }, window: { scrollY: 720, scrollTo: ({ top }: { top: number }) => { scrolls.push(top) } } } const source = readFileSync(new URL('useListReturnState.ts', root), 'utf8') .replace(/^import .*$/gm, '') .replace('export function', 'function') const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext }) const useState = new Function(...Object.keys(bindings), `${code}; return useListReturnState`)( ...Object.values(bindings) ) function create() { const keyword = ref('') const query = ref>({}) const page = ref(3) const expandedIds = ref(new Set(['customer-2'])) const hasMore = ref(true) const calls: number[] = [] let failPage = 0 let duringFetch: (() => void) | undefined const state = useState({ fields: { keyword, query }, page, expandedIds, hasMore, queryKey: () => JSON.stringify([keyword.value, query.value]), async fetchPage(reset: boolean) { const requested = reset ? 1 : page.value + 1 calls.push(requested) if (requested === failPage) return page.value = requested expandedIds.value = new Set() duringFetch?.() return true } }) return { state, keyword, query, page, expandedIds, calls, failAt: (value: number) => { failPage = value }, duringFetch: (fn: () => void) => { duringFetch = fn } } } function navigate(to: string) { const from = path path = to for (const fn of afterEach) fn({ path: to }, { path: from }) } function openDetail() { leave({ path: '/m/crm/customer/customer-2' }) navigate('/m/crm/customer/customer-2') unmount() } return { create, navigate, openDetail, user, scrolls } } test('return restores filters, re-fetches loaded pages and restores expanded cards and scroll', async () => { const env = returnStateHarness() const list = env.create() list.keyword.value = '山峡' list.query.value = { levelIn: 'A' } env.openDetail() list.query.value.levelIn = 'B' env.navigate('/m/crm/customer') const returned = env.create() await returned.state.restore() assert.equal(returned.keyword.value, '山峡') assert.deepEqual(returned.query.value, { levelIn: 'A' }) assert.deepEqual(returned.calls, [1, 2, 3]) assert.deepEqual([...returned.expandedIds.value], ['customer-2']) assert.deepEqual(env.scrolls, [720]) assert.equal(returned.state.restoring.value, false) }) for (const change of ['session', 'user', 'tenant', 'roles', 'outside', 'approval'] as const) { test(`saved state is not reused after ${change} changes`, async () => { const env = returnStateHarness() const list = env.create() list.keyword.value = 'private keyword' env.openDetail() if (change === 'session') env.user.accessToken = 'session-b' if (change === 'user') env.user.getUserInfo.userId = 'b' if (change === 'tenant') env.user.getUserInfo.tenantNo = 'tenant-b' if (change === 'roles') env.user.getUserInfo.roles = ['viewer'] if (change === 'outside') env.navigate('/workbench') env.navigate(change === 'approval' ? '/m/crm/customer/approval' : '/m/crm/customer') const returned = env.create() await returned.state.restore() assert.equal(returned.keyword.value, '') assert.deepEqual(returned.calls, [1]) assert.deepEqual(env.scrolls, []) }) } test('failed page during restoration stops without skipping it or jumping to an invalid position', async () => { const env = returnStateHarness() env.create() env.openDetail() env.navigate('/m/crm/customer') const returned = env.create() returned.failAt(2) await returned.state.restore() assert.deepEqual(returned.calls, [1, 2]) assert.equal(returned.page.value, 1) assert.deepEqual(env.scrolls, []) assert.equal(returned.state.restoring.value, false) }) test('typing a new query during restoration cancels old pagination and scroll restoration', async () => { const env = returnStateHarness() env.create() env.openDetail() env.navigate('/m/crm/customer') const returned = env.create() returned.duringFetch(() => { returned.keyword.value = 'new query' }) await returned.state.restore() assert.deepEqual(returned.calls, [1]) assert.deepEqual(env.scrolls, []) }) class ForbiddenError extends Error { code = 403 } function requestHarness(file: string) { const requests: Array<{ params: any resolve: (value: any) => void reject: (error: Error) => void }> = [] const request = (params: any) => new Promise((resolve, reject) => { requests.push({ params, resolve, reject }) }) const rows = ref([{ id: 'old' }]) const page = ref(1) const state: Record = { rows, products: rows, quotes: rows, contracts: rows, page, pageNum: page, props: { mode: 'manage' }, requestSeq: 0, disposed: false, userStore: { accessToken: 'session', info: { id: 'user-a' } }, cleanContractQuery, pageSize: 20, HttpError: ForbiddenError, ApiStatus: { forbidden: 403 }, ElMessage: { error() {} }, fetchCustomerList: request, fetchMyCustomerList: request, fetchToFollowCustomerList: request, fetchKeyCustomerList: request, fetchProductList: request, listQuote: request, listContract: request, ensureCategoryPathMap: async () => {}, ensureModelOptions: async () => {}, resolveCurrentUserId: () => 'user-a', clearSelection() {}, buildProductListQuery: (query: any) => query, buildQuoteListQuery: (query: any) => query, buildContractListQuery: (query: any) => query, unwrapData: (data: any) => data, unwrapRows: (data: any) => data.rows, selected: ref([]), selectedIds: ref([]), cleanProductQuery, cleanQuoteQuery, visibleColumns: ref(productColumnDefs.map((d) => d.prop)), expandedIds: ref(new Set()), query: ref({}), total: ref(40), loading: ref(false), forbidden: ref(false), activeView: ref('all') } for (const key of [ 'keyword', 'categoryId', 'model', 'priceMin', 'priceMax', 'view', 'quoteType', 'contractType', 'quoteDateBegin', 'quoteDateEnd', 'signDateBegin', 'signDateEnd', 'minAmount', 'maxAmount', 'error', 'errorText', 'failedRequest', 'filtersOpen', 'poolOpen', 'speedOpen', 'selectMode' ]) state[key] = ref('') state.cleanQuery = () => { state.query.value = cleanCustomerColumnQuery( state.query.value, customerColumnDefs.map((d) => d.prop) ) } state.currentQuery = computed(() => buildMobileProductQuery({ query: state.query.value, keyword: state.keyword.value, categoryId: state.categoryId.value, model: state.model.value, visible: state.visibleColumns.value, page: 1, pageSize: 20 }) ) if (file.startsWith('quote')) { state.visibleColumns.value = quoteColumnDefs.map((d) => d.prop) state.currentQuery = computed(() => buildMobileQuoteQuery({ mode: 'manage', view: state.view.value, query: state.query.value, visible: state.visibleColumns.value, keyword: state.keyword.value, currentUserId: 'user-a', pageNum: 1, pageSize: 20 }) ) } if (file.startsWith('contract')) { state.currentQuery = computed(() => buildMobileContractQuery({ mode: 'manage', view: state.view.value, query: state.query.value, keyword: state.keyword.value, currentUserId: 'user-a', pageNum: 1, pageSize: 20 }) ) } const fetchPage = sourceFunction(file, 'fetchPage', state, ['requestSnapshot']) const flush = () => new Promise((resolve) => setImmediate(resolve)) return { state, requests, fetchPage, flush } } for (const file of pages) { test(`${file}: newest search wins and a failed refresh cannot display stale rows`, async () => { const { state, requests, fetchPage, flush } = requestHarness(file) state.keyword.value = 'old query' const old = fetchPage(true) await flush() assert.deepEqual(state.rows.value, []) state.keyword.value = 'new query' const latest = fetchPage(true) await flush() requests[1].resolve({ rows: [{ id: 'new' }], total: 1 }) assert.equal(await latest, true) requests[0].resolve({ rows: [{ id: 'old' }], total: 1 }) await old assert.deepEqual(state.rows.value, [{ id: 'new' }]) const failed = fetchPage(true) await flush() requests[2].reject(new Error('offline')) await failed assert.deepEqual(state.rows.value, []) assert.equal(state.loading.value, false) }) test(`${file}: a failed next page retries the same page and 403 removes business rows`, async () => { const { state, requests, fetchPage, flush } = requestHarness(file) const failed = fetchPage(false) await flush() requests[0].reject(new Error('offline')) await failed assert.equal(state.page.value, 1) assert.deepEqual(state.rows.value, [{ id: 'old' }]) const retry = fetchPage(false) await flush() assert.equal(requests[0].params.page ?? requests[0].params.pageNum, 2) assert.equal(requests[1].params.page ?? requests[1].params.pageNum, 2) requests[1].resolve({ rows: [{ id: 'page-2' }], total: 40 }) assert.equal(await retry, true) assert.equal(state.page.value, 2) assert.deepEqual(state.rows.value, [{ id: 'old' }, { id: 'page-2' }]) const forbidden = fetchPage(true) await flush() requests[2].reject(new ForbiddenError('forbidden')) await forbidden assert.equal(state.forbidden.value, true) assert.deepEqual(state.rows.value, []) }) }