import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import { ref, nextTick, watch } from 'vue' import ts from 'typescript' import { parse } from '@vue/compiler-sfc' import * as routeUtils from '../src/utils/mp-webview' import * as listState from '../src/views/m/crm/contract/listState' function harness(mode: 'manage' | 'approve') { const base = mode === 'approve' ? '/m/crm/contract/approval' : '/m/crm/contract' let route: any = { path: base, query: {} } let leave: any, unmount: any const hooks = new Set() const scrolls: number[] = [] const bindings = { ref, nextTick, watch, useRoute: () => route, useRouter: () => ({ afterEach(fn: any) { hooks.add(fn) return () => hooks.delete(fn) } }), useUserStore: () => ({ accessToken: 'token', getUserInfo: { id: 'u', tenantNo: 'T' } }), onBeforeRouteLeave: (fn: any) => { leave = fn }, onBeforeUnmount: (fn: any) => { unmount = fn }, window: { scrollY: 720, scrollTo: ({ top }: any) => scrolls.push(top) } } const source = readFileSync( new URL('../src/views/m/crm/useListReturnState.ts', import.meta.url), 'utf8' ) .replace(/^import .*$/gm, '') .replace('export function', 'function') const useState = new Function( ...Object.keys(bindings), ts.transpile(source, { target: ts.ScriptTarget.ESNext }) + ';return useListReturnState' )(...Object.values(bindings)) // Evaluate the real contract call-site so this catches missing predicate wiring, not only the helper. const ast = ts.createSourceFile( 'list.ts', parse( readFileSync( new URL('../src/views/m/crm/contract/MpContractList.vue', import.meta.url), 'utf8' ) ).descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const statement = ast.statements.find( (n) => ts.isVariableStatement(n) && n.declarationList.declarations.some((d) => d.name.getText(ast) === 'listReturnState') )! function create() { let failPage = 0 const keyword = ref(''), query = ref({}), view = ref(mode === 'approve' ? 'submitted' : 'all'), tabQueries = ref({}) const pageNum = ref(3), expandedIds = ref(new Set(['Q1'])), hasMore = ref(true) const requests: any[] = [] const local = { ...listState, ...routeUtils, props: { mode }, keyword, query, view, tabQueries, pageNum, expandedIds, hasMore, useListReturnState: useState, requestSnapshot: () => JSON.stringify([keyword.value, query.value, view.value]), fetchPage: async (reset: boolean) => { const page = reset ? 1 : pageNum.value + 1 requests.push({ page, keyword: keyword.value, query: query.value }) if (page === failPage) return false pageNum.value = page expandedIds.value = new Set() return true } } const state = new Function( ...Object.keys(local), ts.transpile(statement.getText(ast), { target: ts.ScriptTarget.ESNext }) + ';return listReturnState' )(...Object.values(local)) const template = parse( readFileSync( new URL('../src/views/m/crm/contract/MpContractList.vue', import.meta.url), 'utf8' ) ).descriptor.template!.content const retryExpression = template.match(/]*@click="([^"]+)"[^>]*>\s*重试/)[1] const retry = () => new Function('listReturnState', 'contracts', 'fetchPage', 'return ' + retryExpression)( state, [], local.fetchPage ) return { state, keyword, query, pageNum, expandedIds, requests, retry, failAt: (page: number) => { failPage = page } } } function navigate(to: any) { const from = route route = to for (const hook of hooks) hook(to, from) } function detail() { const to = { path: '/m/crm/contract/Q1', query: { from: mode } } leave(to) navigate(to) unmount() } return { base, create, navigate, detail, scrolls } } for (const mode of ['manage', 'approve'] as const) { test(`${mode}: contract/related/contract round trip restores search, filters, pages and scroll`, async () => { const h = harness(mode), first = h.create() first.keyword.value = '移动验收' first.query.value = { amountMin: '10' } h.detail() h.navigate({ path: '/m/crm/customer/R1', query: { from: 'contract', returnContractId: 'Q1', contractFrom: mode } }) h.navigate({ path: '/m/crm/quote/Q2', query: { from: 'contract', returnContractId: 'Q1', contractFrom: mode } }) h.navigate({ path: '/m/crm/customer/R1', query: { from: 'quote', returnQuoteId: 'Q2', quoteFrom: 'contract', returnContractId: 'Q1', contractFrom: mode } }) h.navigate({ path: '/m/crm/quote/Q2', query: { from: 'contract', returnContractId: 'Q1', contractFrom: mode } }) h.navigate({ path: '/m/crm/contract/Q1', query: { from: mode } }) h.navigate({ path: h.base, query: {} }) const back = h.create() await back.state.restore() assert.equal(back.keyword.value, '移动验收') assert.deepEqual(back.query.value, { amountMin: '10' }) assert.deepEqual( back.requests.map((r: any) => r.page), [1, 2, 3] ) assert.deepEqual([...back.expandedIds.value], ['Q1']) assert.deepEqual(h.scrolls, [720]) }) } test('unrelated customer paths and forged contract return IDs discard the contract snapshot', async () => { for (const query of [ {}, { from: 'customer', returnContractId: 'Q1' }, { from: 'contract', returnContractId: '../Q1' } ]) { const h = harness('manage'), first = h.create() first.keyword.value = 'private' h.detail() h.navigate({ path: '/m/crm/customer/R1', query }) h.navigate({ path: '/m/crm/contract/Q1', query: {} }) h.navigate({ path: h.base, query: {} }) const back = h.create() await back.state.restore() assert.equal(back.keyword.value, '') } }) for (const mode of ['manage', 'approve'] as const) for (const failedPage of [1, 2]) { test(`${mode}: retry after restoration failure at page ${failedPage} preserves original pagination and scroll`, async () => { const h = harness(mode), first = h.create() first.keyword.value = '保存的搜索' h.detail() h.navigate({ path: h.base, query: {} }) const back = h.create() back.failAt(failedPage) await back.state.restore() assert.deepEqual(h.scrolls, []) back.failAt(0) await back.retry() assert.equal(back.pageNum.value, 3) assert.equal(back.keyword.value, '保存的搜索') assert.deepEqual([...back.expandedIds.value], ['Q1']) assert.deepEqual(h.scrolls, [720]) assert.deepEqual( back.requests.map((r: any) => r.page), failedPage === 1 ? [1, 1, 2, 3] : [1, 2, 1, 2, 3] ) }) }