| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- 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<any>()
- 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<typeof effectScope>[] = []
- 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()
- }
- })
- }
|