426 lines
14 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
}
|