import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import { ref, reactive, computed, nextTick, watch, effectScope } from 'vue' import { useServerColumnFilters } from '../src/composables/useServerColumnFilters' import { plPlanColumnFilterDefs } from '../src/views/pl/plan/columnFilterDefs' import { applyMergedCustomerQueryParams, applyContractSideQueryParams } from '../src/utils/pl-plan-pair-list-query' function harness(file: string) { const base = `/m/pl/${file}` let path = base let leave: any let unmount: any const hooks = new Set() const scrolls: number[] = [] const user = reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a' } }) const bindings = { ref, nextTick, watch, useRoute: () => ({ path }), useRouter: () => ({ afterEach: (fn: any) => { hooks.add(fn) return () => hooks.delete(fn) } }), useUserStore: () => user, onBeforeRouteLeave: (fn: any) => { leave = fn }, onBeforeUnmount: (fn: any) => { unmount = fn }, window: { scrollY: 720, scrollTo: ({ top }: any) => scrolls.push(top) } } const helper = 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(helper, { target: ts.ScriptTarget.ESNext }) + ';return useListReturnState' )(...Object.values(bindings)) const descriptor = parse( readFileSync(new URL(`../src/views/m/pl/${file}/index.vue`, import.meta.url), 'utf8') ).descriptor const source = ts.createSourceFile( 'page.ts', descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const scopes: ReturnType[] = [] function create(enableRetry = true, deferDetails = false) { const requests: any[] = [] const detailRequests: any[] = [] const state: any = { userStore: user, page: ref(1), plans: ref([]), total: ref(60), loading: ref(false), error: ref(''), forbidden: ref(false), failedMore: ref(false), selected: ref(new Set()), expandedIds: ref(new Set()), pageSize: 20, planDetails: { reset() {}, ensure: (id: string) => deferDetails ? new Promise((resolve) => detailRequests.push({ id, resolve })) : Promise.resolve(true) }, plPlanListTabToFilters: () => ({}), ...useServerColumnFilters(plPlanColumnFilterDefs), sort: reactive({ sortField: '', sortOrder: 'desc' }), applyMergedCustomerQueryParams, applyContractSideQueryParams, HttpError: Error, ApiStatus: { forbidden: 403 }, planApi: { list: (params: any) => new Promise((resolve, reject) => requests.push({ params, resolve, reject })) } } for (const key of [ 'keyword', 'appliedKeyword', 'activeTab', 'dateShortcut', 'customFrom', 'customTo', 'customDateLabel', 'tab', 'dateKind', 'readyKind' ]) state[key] = ref('') for (const key of ['dateRange', 'dates']) state[key] = ref([]) state.filterState = computed({ get: () => state.filters, set: (value) => { Object.keys(state.filters).forEach((key) => delete state.filters[key]) Object.assign(state.filters, value) } }) state.sortState = computed({ get: () => state.sort, set: (value) => Object.assign(state.sort, value) }) state.hasMore = computed(() => state.plans.value.length < state.total.value) state.query = () => ({ q: state.appliedKeyword.value, ...state.filterState.value }) const names = ['requestKey', 'buildParams', 'fetchPage', 'load', 'retryLoad'] const code = source.statements .filter( (n) => (ts.isFunctionDeclaration(n) && names.includes(n.name?.text || '')) || (ts.isVariableStatement(n) && n.declarationList.declarations.some((d) => ['sessionScope', 'listReturnState'].includes(d.name.getText(source)) )) ) .map((n) => n.getText(source)) .join('\n') const scope = effectScope() scopes.push(scope) const api = scope.run(() => new Function( ...Object.keys(state), 'useListReturnState', `let disposed=false;let requestVersion=0;${ts.transpile(code, { target: ts.ScriptTarget.ESNext })};return {state:listReturnState,retry:typeof retryLoad==='function'?retryLoad:undefined,run:${file === 'plan' ? 'fetchPage' : '(reset)=>load(!reset)'}}` )(...Object.values(state), (options: any) => { if (!enableRetry) delete options.retryFailedRestore return useState(options) }) ) return { ...state, ...api, requests, detailRequests } } function navigate(to: string) { const from = path path = to for (const fn of hooks) fn({ path: to }, { path: from }) } function returned(enableRetry = true, via: string[] = [], deferDetails = false) { const first = create() first.keyword.value = first.appliedKeyword.value = 'saved' first.page.value = 3 first.expandedIds.value = new Set(['row-2']) leave({ path: `${base}/row-2` }) navigate(`${base}/row-2`) unmount() for (const path of via) navigate(path) navigate(base) return create(enableRetry, deferDetails) } return { create, returned, user, scrolls, leave: () => leave({ path: '/workbench' }), unmount: () => unmount(), close: () => scopes.forEach((s) => s.stop()), template: descriptor.template!.content } } async function flush() { for (let i = 0; i < 8; i++) await nextTick() } async function success(h: any, index: number) { assert.ok(h.requests[index], `request ${index} exists`) const page = h.requests[index].params.page h.requests[index].resolve({ list: [{ id: `row-${page}` }], total: 60 }) await flush() } test('plan: detail to edit and back to list preserves search, pages, expansion and scroll', async () => { const env = harness('plan') try { const h = env.returned(true, ['/m/pl/plan/row-2/edit']) const restoring = h.state.restore() assert.equal(h.keyword.value, 'saved') for (let i = 0; i < 3; i++) await success(h, i) await restoring assert.equal(h.page.value, 3) assert.deepEqual([...h.expandedIds.value], ['row-2']) assert.deepEqual(env.scrolls, [720]) } finally { env.close() } }) for (const file of ['plan', 'plan-cert']) for (const result of ['success', 'failure', 'identity']) { test(`${file}: return waits for expanded details before scroll and guards ${result}`, async () => { const env = harness(file) try { const h = env.returned(true, [], true) const restoring = h.state.restore() for (let i = 0; i < 3; i++) await success(h, i) assert.deepEqual(env.scrolls, []) assert.equal(h.state.restoring.value, true) assert.equal(h.detailRequests.length, 1) if (result === 'identity') env.user.accessToken = 'new' h.detailRequests[0].resolve(result !== 'failure') await restoring assert.deepEqual(env.scrolls, result === 'success' ? [720] : []) assert.equal(h.state.restoring.value, false) if (result === 'failure') { assert.match(h.error.value, /重试/) const retry = h.retry() for (let i = 3; i < 6; i++) await success(h, i) assert.deepEqual(env.scrolls, []) h.detailRequests[1].resolve(true) await retry assert.deepEqual(env.scrolls, [720]) assert.equal(h.error.value, '') } if (result === 'identity') assert.equal(h.error.value, '') } finally { env.close() } }) } for (const file of ['plan', 'plan-cert']) { for (const failedPage of [1, 2]) test(`${file}: page ${failedPage} restoration failure retries via real page handler then consumes snapshot`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() if (failedPage === 2) await success(h, 0) h.requests[failedPage - 1].reject(new Error('offline')) await first assert.equal(h.keyword.value, 'saved') assert.equal(h.state.restoring.value, false) assert.equal(typeof h.retry, 'function', 'page must route retry through restoration') assert.match(env.template, /@click="retryLoad"/) const start = h.requests.length const retry = h.retry() await flush() for (let i = 0; i < 3; i++) await success(h, start + i) await retry assert.equal(h.page.value, 3) assert.deepEqual([...h.expandedIds.value], ['row-2']) assert.deepEqual(env.scrolls, [720]) const ordinary = h.retry() await flush() await success(h, h.requests.length - 1) await ordinary assert.equal(h.page.value, 4) assert.deepEqual(env.scrolls, [720]) } finally { env.close() } }) test(`${file}: duplicate retry does not create requests or release restoring`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() h.requests[0].reject(new Error('offline')) await first assert.equal(typeof h.retry, 'function') const retry = h.retry() await flush() await h.retry() assert.equal(h.requests.length, 2) assert.equal(h.state.restoring.value, true) for (let i = 1; i <= 3; i++) await success(h, i) await retry assert.deepEqual(env.scrolls, [720]) } finally { env.close() } }) for (const change of ['query', 'account', 'tenant', 'unmount', 'leave']) test(`${file}: ${change} invalidates failed snapshot permanently`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() h.requests[0].reject(new Error('offline')) await first if (change === 'query') { h.keyword.value = 'new' h.keyword.value = 'saved' } if (change === 'account') { env.user.accessToken = 'b' env.user.accessToken = 'a' } if (change === 'tenant') { env.user.getUserInfo.tenantNo = 'b' env.user.getUserInfo.tenantNo = 'a' } if (change === 'unmount') env.unmount() if (change === 'leave') env.leave() assert.equal(typeof h.retry, 'function') const retry = h.retry() await flush() if (h.requests.length > 1) await success(h, 1) await retry assert.ok(h.requests.length <= 2) assert.deepEqual(env.scrolls, []) assert.deepEqual([...h.expandedIds.value], []) } finally { env.close() } }) test(`${file}: obsolete finally cannot release a newer restore`, async () => { const env = harness(file) try { const h = env.returned() const old = h.state.restore() h.keyword.value = 'new' const fresh = h.state.restore() h.requests[0].reject(new Error('old')) await old assert.equal(h.state.restoring.value, true) await success(h, 1) await fresh assert.equal(h.state.restoring.value, false) assert.deepEqual(env.scrolls, []) } finally { env.close() } }) for (const more of [false, true]) test(`${file}: ordinary ${more ? 'pagination' : 'initial'} retry keeps existing semantics`, async () => { const env = harness(file) try { const h = env.create() const failed = h.run(!more) h.requests[0].reject(new Error('offline')) await failed assert.equal(typeof h.retry, 'function') const retry = h.retry() await flush() assert.equal(h.requests[1].params.page, more ? 2 : 1) await success(h, 1) await retry assert.deepEqual(env.scrolls, []) } finally { env.close() } }) } for (const file of ['plan', 'plan-cert']) { test(`${file}: absent opt-in retains one-shot CRM default restoration`, async () => { const env = harness(file) try { const h = env.returned(false) const first = h.state.restore() await success(h, 0) h.requests[1].reject(new Error('offline')) await first const retry = h.retry() await success(h, 2) await retry assert.equal(h.page.value, 2) assert.equal(h.requests.length, 3) assert.deepEqual([...h.expandedIds.value], []) assert.deepEqual(env.scrolls, []) } finally { env.close() } }) for (const invalidation of ['query', 'unmount', 'leave']) { test(`${file}: pending retry ${invalidation} cannot restore expansion or scroll`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() h.requests[0].reject(new Error('offline')) await first const retry = h.retry() if (invalidation === 'query') { h.keyword.value = 'new' h.keyword.value = 'saved' } if (invalidation === 'unmount') env.unmount() if (invalidation === 'leave') env.leave() await success(h, 1) await retry assert.equal(h.requests.length, 2) assert.deepEqual([...h.expandedIds.value], []) assert.deepEqual(env.scrolls, []) assert.equal(h.state.restoring.value, false) } finally { env.close() } }) } } for (const file of ['plan', 'plan-cert']) { test(`${file}: equivalent user info replacement preserves failed return snapshot`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() h.requests[0].reject(new Error('offline')) await first env.user.getUserInfo = { tenantNo: 'a' } const retry = h.retry() for (let i = 1; i <= 3; i++) await success(h, i) await retry assert.equal(h.page.value, 3) assert.deepEqual([...h.expandedIds.value], ['row-2']) assert.deepEqual(env.scrolls, [720]) } finally { env.close() } }) }