Files
shanxia2.0/web-admin/scripts/mobile-pl-return-retry.test.ts
T

426 lines
14 KiB
TypeScript

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()
}
})
}