521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import { compile, createRenderer, defineComponent, h, nextTick, reactive, ref } from 'vue'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
|
|
const pages = [
|
|
'customer/index.vue',
|
|
'product/index.vue',
|
|
'quote/MpQuoteList.vue',
|
|
'contract/MpContractList.vue'
|
|
]
|
|
const root = new URL('../src/views/m/crm/', import.meta.url)
|
|
|
|
// Vue's real compiler and renderer run against in-memory host nodes. No browser is launched.
|
|
function mountTemplate(file: string, mode = 'manage') {
|
|
type Node = { type: string; children: Node[]; parent?: Node; props: Record<string, any> }
|
|
const node = (type: string): Node =>
|
|
({
|
|
type,
|
|
children: [],
|
|
props: {},
|
|
style: {},
|
|
addEventListener() {},
|
|
removeEventListener() {}
|
|
}) as Node
|
|
const renderer = createRenderer<Node, Node>({
|
|
createElement: node,
|
|
createText: node,
|
|
createComment: node,
|
|
setText() {},
|
|
setElementText() {},
|
|
parentNode: (el) => el.parent!,
|
|
nextSibling: (el) => el.parent?.children[el.parent.children.indexOf(el) + 1] || null,
|
|
patchProp: (el, key, _old, value) => {
|
|
el.props[key] = value
|
|
},
|
|
insert(el, parent, anchor) {
|
|
if (el.parent) el.parent.children.splice(el.parent.children.indexOf(el), 1)
|
|
el.parent = parent
|
|
const index = anchor ? parent.children.indexOf(anchor) : -1
|
|
parent.children.splice(index < 0 ? parent.children.length : index, 0, el)
|
|
},
|
|
remove(el) {
|
|
el.parent?.children.splice(el.parent.children.indexOf(el), 1)
|
|
}
|
|
})
|
|
const state = reactive<Record<string, any>>({
|
|
loading: false,
|
|
forbidden: false,
|
|
keyword: '山峡',
|
|
rows: [],
|
|
products: [],
|
|
cards: [],
|
|
views: [],
|
|
viewTabs: [],
|
|
selected: [],
|
|
selectedIds: new Set(),
|
|
previewFields: [],
|
|
filtersOpen: true,
|
|
moreOpen: true,
|
|
submitForm: { approverIds: [] },
|
|
moreDraft: {},
|
|
mode,
|
|
error: '',
|
|
errorText: ''
|
|
})
|
|
const fallback = () => []
|
|
const context = new Proxy(state, {
|
|
has: (_, key) =>
|
|
typeof key === 'string' &&
|
|
!key.startsWith('_') &&
|
|
!['String', 'Boolean', 'Number'].includes(key),
|
|
get: (target, key) =>
|
|
key === Symbol.unscopables ? undefined : key in target ? target[key as string] : fallback
|
|
})
|
|
const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8'))
|
|
const render = compile(descriptor.template!.content)
|
|
const passthrough = defineComponent({
|
|
setup:
|
|
(_, { slots }) =>
|
|
() =>
|
|
h(
|
|
'stub',
|
|
Object.values(slots).flatMap((slot) => slot?.())
|
|
)
|
|
})
|
|
const header = defineComponent({
|
|
setup:
|
|
(_, { slots }) =>
|
|
() =>
|
|
h(
|
|
'input',
|
|
Object.values(slots).flatMap((slot) => slot?.())
|
|
)
|
|
})
|
|
const app = renderer.createApp({ render: () => render.call(context, context, []) })
|
|
const tags = [...descriptor.template!.content.matchAll(/<([A-Z][\w]+|van-[\w-]+)/g)].map(
|
|
(match) => match[1]
|
|
)
|
|
for (const tag of new Set(tags)) {
|
|
const type =
|
|
tag === 'CustomerFilters'
|
|
? 'filters'
|
|
: tag === 'van-popup'
|
|
? 'popup'
|
|
: tag === 'MpListPageSkeleton'
|
|
? 'skeleton'
|
|
: ''
|
|
app.component(
|
|
tag,
|
|
tag === 'MpBusinessHeader'
|
|
? header
|
|
: type
|
|
? defineComponent({
|
|
setup:
|
|
(_, { slots }) =>
|
|
() =>
|
|
h(type, slots.default?.())
|
|
})
|
|
: passthrough
|
|
)
|
|
}
|
|
const container = node('root')
|
|
app.mount(container)
|
|
const find = (type: string, start = container): Node | undefined => {
|
|
if (start.type === type) return start
|
|
for (const child of start.children) {
|
|
const found = find(type, child)
|
|
if (found) return found
|
|
}
|
|
}
|
|
return { state, find, app }
|
|
}
|
|
|
|
for (const { page, mode } of pages.flatMap((page) =>
|
|
(page.includes('Mp') ? ['manage', 'approve'] : ['manage']).map((mode) => ({ page, mode }))
|
|
)) {
|
|
test(`${page} (${mode}): search retains the same header and filter nodes while results reload`, async () => {
|
|
const { state, find, app } = mountTemplate(page, mode)
|
|
const input = find('input')
|
|
assert.ok(input)
|
|
const filterType = page.startsWith('customer') ? 'filters' : 'popup'
|
|
const filters = find(filterType)
|
|
assert.ok(filters)
|
|
state.loading = true
|
|
await nextTick()
|
|
assert.ok(find('input') === input, 'refresh must not unmount the search input')
|
|
assert.ok(find(filterType) === filters, 'open filter contents must stay mounted')
|
|
assert.ok(find('skeleton'), 'loading still shows a body skeleton')
|
|
state.loading = false
|
|
await nextTick()
|
|
assert.ok(find('input') === input)
|
|
state.forbidden = true
|
|
await nextTick()
|
|
assert.ok(find('input'), '403 keeps navigation available')
|
|
app.unmount()
|
|
})
|
|
}
|
|
|
|
function sourceFunction(
|
|
file: string,
|
|
name: string,
|
|
bindings: Record<string, any>,
|
|
dependencies: string[] = []
|
|
) {
|
|
const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8'))
|
|
const source = ts.createSourceFile(
|
|
file,
|
|
descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const declarations = source.statements.filter(
|
|
(item) =>
|
|
ts.isFunctionDeclaration(item) && [name, ...dependencies].includes(item.name?.text || '')
|
|
)
|
|
const code = ts.transpile(declarations.map((item) => item.getText(source)).join('\n'), {
|
|
target: ts.ScriptTarget.ESNext
|
|
})
|
|
return new Function(...Object.keys(bindings), `${code}; return ${name}`)(
|
|
...Object.values(bindings)
|
|
)
|
|
}
|
|
|
|
for (const page of pages) {
|
|
test(`${page}: shell ready restores the saved query instead of clearing it`, async () => {
|
|
let restores = 0
|
|
const bindings: Record<string, any> = {
|
|
props: { mode: 'manage' },
|
|
categoryRequestSeq: 0,
|
|
modelRequestSeq: 0,
|
|
categoryMapLoaded: false,
|
|
modelOptionsLoaded: false,
|
|
reload() {},
|
|
clearSelection() {},
|
|
listReturnState: {
|
|
restore: async () => {
|
|
restores++
|
|
}
|
|
}
|
|
}
|
|
for (const key of [
|
|
'keyword',
|
|
'categoryId',
|
|
'model',
|
|
'priceMin',
|
|
'priceMax',
|
|
'view',
|
|
'quoteType',
|
|
'contractType',
|
|
'amountRange',
|
|
'quoteDateBegin',
|
|
'quoteDateEnd',
|
|
'signDateBegin',
|
|
'signDateEnd',
|
|
'minAmount',
|
|
'maxAmount',
|
|
'speedOpen',
|
|
'selectMode',
|
|
'expandedIds'
|
|
]) {
|
|
bindings[key] = { value: key === 'keyword' ? '山峡' : '' }
|
|
}
|
|
await sourceFunction(page, 'onShellReady', bindings)()
|
|
assert.equal(bindings.keyword.value, '山峡')
|
|
assert.equal(restores, 1, 'ready must restore list context after authentication')
|
|
})
|
|
}
|
|
|
|
function returnStateHarness() {
|
|
let path = '/m/crm/customer'
|
|
let leave: (to: { path: string }) => void
|
|
let unmount: () => void
|
|
const afterEach = new Set<(to: { path: string }, from: { path: string }) => void>()
|
|
const user = {
|
|
accessToken: 'session-a',
|
|
getUserInfo: { userId: 'a', tenantNo: 'tenant-a', roles: ['sales'] }
|
|
}
|
|
const scrolls: number[] = []
|
|
const bindings = {
|
|
ref,
|
|
nextTick,
|
|
useRoute: () => ({ path }),
|
|
useRouter: () => ({
|
|
afterEach(fn: any) {
|
|
afterEach.add(fn)
|
|
return () => afterEach.delete(fn)
|
|
}
|
|
}),
|
|
useUserStore: () => user,
|
|
onBeforeRouteLeave: (fn: typeof leave) => {
|
|
leave = fn
|
|
},
|
|
onBeforeUnmount: (fn: typeof unmount) => {
|
|
unmount = fn
|
|
},
|
|
window: {
|
|
scrollY: 720,
|
|
scrollTo: ({ top }: { top: number }) => {
|
|
scrolls.push(top)
|
|
}
|
|
}
|
|
}
|
|
const source = readFileSync(new URL('useListReturnState.ts', root), 'utf8')
|
|
.replace(/^import .*$/gm, '')
|
|
.replace('export function', 'function')
|
|
const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext })
|
|
const useState = new Function(...Object.keys(bindings), `${code}; return useListReturnState`)(
|
|
...Object.values(bindings)
|
|
)
|
|
function create() {
|
|
const keyword = ref('')
|
|
const query = ref<Record<string, string>>({})
|
|
const page = ref(3)
|
|
const expandedIds = ref(new Set(['customer-2']))
|
|
const hasMore = ref(true)
|
|
const calls: number[] = []
|
|
let failPage = 0
|
|
let duringFetch: (() => void) | undefined
|
|
const state = useState({
|
|
fields: { keyword, query },
|
|
page,
|
|
expandedIds,
|
|
hasMore,
|
|
queryKey: () => JSON.stringify([keyword.value, query.value]),
|
|
async fetchPage(reset: boolean) {
|
|
const requested = reset ? 1 : page.value + 1
|
|
calls.push(requested)
|
|
if (requested === failPage) return
|
|
page.value = requested
|
|
expandedIds.value = new Set()
|
|
duringFetch?.()
|
|
return true
|
|
}
|
|
})
|
|
return {
|
|
state,
|
|
keyword,
|
|
query,
|
|
page,
|
|
expandedIds,
|
|
calls,
|
|
failAt: (value: number) => {
|
|
failPage = value
|
|
},
|
|
duringFetch: (fn: () => void) => {
|
|
duringFetch = fn
|
|
}
|
|
}
|
|
}
|
|
function navigate(to: string) {
|
|
const from = path
|
|
path = to
|
|
for (const fn of afterEach) fn({ path: to }, { path: from })
|
|
}
|
|
function openDetail() {
|
|
leave({ path: '/m/crm/customer/customer-2' })
|
|
navigate('/m/crm/customer/customer-2')
|
|
unmount()
|
|
}
|
|
return { create, navigate, openDetail, user, scrolls }
|
|
}
|
|
|
|
test('return restores filters, re-fetches loaded pages and restores expanded cards and scroll', async () => {
|
|
const env = returnStateHarness()
|
|
const list = env.create()
|
|
list.keyword.value = '山峡'
|
|
list.query.value = { levelIn: 'A' }
|
|
env.openDetail()
|
|
list.query.value.levelIn = 'B'
|
|
env.navigate('/m/crm/customer')
|
|
const returned = env.create()
|
|
await returned.state.restore()
|
|
assert.equal(returned.keyword.value, '山峡')
|
|
assert.deepEqual(returned.query.value, { levelIn: 'A' })
|
|
assert.deepEqual(returned.calls, [1, 2, 3])
|
|
assert.deepEqual([...returned.expandedIds.value], ['customer-2'])
|
|
assert.deepEqual(env.scrolls, [720])
|
|
assert.equal(returned.state.restoring.value, false)
|
|
})
|
|
|
|
for (const change of ['session', 'user', 'tenant', 'roles', 'outside', 'approval'] as const) {
|
|
test(`saved state is not reused after ${change} changes`, async () => {
|
|
const env = returnStateHarness()
|
|
const list = env.create()
|
|
list.keyword.value = 'private keyword'
|
|
env.openDetail()
|
|
if (change === 'session') env.user.accessToken = 'session-b'
|
|
if (change === 'user') env.user.getUserInfo.userId = 'b'
|
|
if (change === 'tenant') env.user.getUserInfo.tenantNo = 'tenant-b'
|
|
if (change === 'roles') env.user.getUserInfo.roles = ['viewer']
|
|
if (change === 'outside') env.navigate('/workbench')
|
|
env.navigate(change === 'approval' ? '/m/crm/customer/approval' : '/m/crm/customer')
|
|
const returned = env.create()
|
|
await returned.state.restore()
|
|
assert.equal(returned.keyword.value, '')
|
|
assert.deepEqual(returned.calls, [1])
|
|
assert.deepEqual(env.scrolls, [])
|
|
})
|
|
}
|
|
|
|
test('failed page during restoration stops without skipping it or jumping to an invalid position', async () => {
|
|
const env = returnStateHarness()
|
|
env.create()
|
|
env.openDetail()
|
|
env.navigate('/m/crm/customer')
|
|
const returned = env.create()
|
|
returned.failAt(2)
|
|
await returned.state.restore()
|
|
assert.deepEqual(returned.calls, [1, 2])
|
|
assert.equal(returned.page.value, 1)
|
|
assert.deepEqual(env.scrolls, [])
|
|
assert.equal(returned.state.restoring.value, false)
|
|
})
|
|
|
|
test('typing a new query during restoration cancels old pagination and scroll restoration', async () => {
|
|
const env = returnStateHarness()
|
|
env.create()
|
|
env.openDetail()
|
|
env.navigate('/m/crm/customer')
|
|
const returned = env.create()
|
|
returned.duringFetch(() => {
|
|
returned.keyword.value = 'new query'
|
|
})
|
|
await returned.state.restore()
|
|
assert.deepEqual(returned.calls, [1])
|
|
assert.deepEqual(env.scrolls, [])
|
|
})
|
|
|
|
class ForbiddenError extends Error {
|
|
code = 403
|
|
}
|
|
|
|
function requestHarness(file: string) {
|
|
const requests: Array<{
|
|
params: any
|
|
resolve: (value: any) => void
|
|
reject: (error: Error) => void
|
|
}> = []
|
|
const request = (params: any) =>
|
|
new Promise((resolve, reject) => {
|
|
requests.push({ params, resolve, reject })
|
|
})
|
|
const rows = ref([{ id: 'old' }])
|
|
const page = ref(1)
|
|
const state: Record<string, any> = {
|
|
rows,
|
|
products: rows,
|
|
quotes: rows,
|
|
contracts: rows,
|
|
page,
|
|
pageNum: page,
|
|
props: { mode: 'manage' },
|
|
requestSeq: 0,
|
|
pageSize: 20,
|
|
HttpError: ForbiddenError,
|
|
ApiStatus: { forbidden: 403 },
|
|
ElMessage: { error() {} },
|
|
fetchCustomerList: request,
|
|
fetchMyCustomerList: request,
|
|
fetchToFollowCustomerList: request,
|
|
fetchKeyCustomerList: request,
|
|
fetchProductList: request,
|
|
listQuote: request,
|
|
listContract: request,
|
|
ensureCategoryPathMap: async () => {},
|
|
ensureModelOptions: async () => {},
|
|
resolveCurrentUserId: () => 'user-a',
|
|
clearSelection() {},
|
|
buildProductListQuery: (query: any) => query,
|
|
buildQuoteListQuery: (query: any) => query,
|
|
buildContractListQuery: (query: any) => query,
|
|
unwrapData: (data: any) => data,
|
|
unwrapRows: (data: any) => data.rows,
|
|
selected: ref([]),
|
|
expandedIds: ref(new Set()),
|
|
query: ref({}),
|
|
total: ref(40),
|
|
loading: ref(false),
|
|
forbidden: ref(false),
|
|
activeView: ref('all')
|
|
}
|
|
for (const key of [
|
|
'keyword',
|
|
'categoryId',
|
|
'model',
|
|
'priceMin',
|
|
'priceMax',
|
|
'view',
|
|
'quoteType',
|
|
'contractType',
|
|
'quoteDateBegin',
|
|
'quoteDateEnd',
|
|
'signDateBegin',
|
|
'signDateEnd',
|
|
'minAmount',
|
|
'maxAmount',
|
|
'error',
|
|
'errorText',
|
|
'failedRequest',
|
|
'filtersOpen',
|
|
'poolOpen',
|
|
'speedOpen',
|
|
'selectMode'
|
|
])
|
|
state[key] = ref('')
|
|
const fetchPage = sourceFunction(file, 'fetchPage', state, ['requestSnapshot'])
|
|
const flush = () => new Promise<void>((resolve) => setImmediate(resolve))
|
|
return { state, requests, fetchPage, flush }
|
|
}
|
|
|
|
for (const file of pages) {
|
|
test(`${file}: newest search wins and a failed refresh cannot display stale rows`, async () => {
|
|
const { state, requests, fetchPage, flush } = requestHarness(file)
|
|
state.keyword.value = 'old query'
|
|
const old = fetchPage(true)
|
|
await flush()
|
|
assert.deepEqual(state.rows.value, [])
|
|
state.keyword.value = 'new query'
|
|
const latest = fetchPage(true)
|
|
await flush()
|
|
requests[1].resolve({ rows: [{ id: 'new' }], total: 1 })
|
|
assert.equal(await latest, true)
|
|
requests[0].resolve({ rows: [{ id: 'old' }], total: 1 })
|
|
await old
|
|
assert.deepEqual(state.rows.value, [{ id: 'new' }])
|
|
const failed = fetchPage(true)
|
|
await flush()
|
|
requests[2].reject(new Error('offline'))
|
|
await failed
|
|
assert.deepEqual(state.rows.value, [])
|
|
assert.equal(state.loading.value, false)
|
|
})
|
|
|
|
test(`${file}: a failed next page retries the same page and 403 removes business rows`, async () => {
|
|
const { state, requests, fetchPage, flush } = requestHarness(file)
|
|
const failed = fetchPage(false)
|
|
await flush()
|
|
requests[0].reject(new Error('offline'))
|
|
await failed
|
|
assert.equal(state.page.value, 1)
|
|
assert.deepEqual(state.rows.value, [{ id: 'old' }])
|
|
const retry = fetchPage(false)
|
|
await flush()
|
|
assert.equal(requests[0].params.page ?? requests[0].params.pageNum, 2)
|
|
assert.equal(requests[1].params.page ?? requests[1].params.pageNum, 2)
|
|
requests[1].resolve({ rows: [{ id: 'page-2' }], total: 40 })
|
|
assert.equal(await retry, true)
|
|
assert.equal(state.page.value, 2)
|
|
assert.deepEqual(state.rows.value, [{ id: 'old' }, { id: 'page-2' }])
|
|
const forbidden = fetchPage(true)
|
|
await flush()
|
|
requests[2].reject(new ForbiddenError('forbidden'))
|
|
await forbidden
|
|
assert.equal(state.forbidden.value, true)
|
|
assert.deepEqual(state.rows.value, [])
|
|
})
|
|
}
|