Files
shanxia2.0/web-admin/scripts/mobile-crm-list-state.test.ts
T

595 lines
18 KiB
TypeScript

import { buildMobileContractQuery, cleanContractQuery } from '../src/views/m/crm/contract/listState'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { compile, computed, createRenderer, defineComponent, h, nextTick, reactive, ref } from 'vue'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import {
buildMobileQuoteQuery,
cleanQuoteQuery,
quoteColumnDefs
} from '../src/views/m/crm/quote/listState'
import {
buildMobileProductQuery,
cleanProductQuery,
productColumnDefs
} from '../src/views/m/crm/product/listState'
import { cleanCustomerColumnQuery, customerColumnDefs } from '../src/views/m/crm/customer/columns'
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: [],
categoryPathMap: new Map(),
selectedRows: [],
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 = ['CustomerFilters', 'ProductFilters', 'QuoteFilters', 'ContractFilters'].includes(
tag
)
? '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 = ['customer', 'product', 'quote', 'contract'].some((prefix) =>
page.startsWith(prefix)
)
? '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() {},
syncQuickFilters() {},
previousView: 'all',
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',
'selectedIds'
]) {
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,
disposed: false,
userStore: { accessToken: 'session', info: { id: 'user-a' } },
cleanContractQuery,
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([]),
selectedIds: ref([]),
cleanProductQuery,
cleanQuoteQuery,
visibleColumns: ref(productColumnDefs.map((d) => d.prop)),
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('')
state.cleanQuery = () => {
state.query.value = cleanCustomerColumnQuery(
state.query.value,
customerColumnDefs.map((d) => d.prop)
)
}
state.currentQuery = computed(() =>
buildMobileProductQuery({
query: state.query.value,
keyword: state.keyword.value,
categoryId: state.categoryId.value,
model: state.model.value,
visible: state.visibleColumns.value,
page: 1,
pageSize: 20
})
)
if (file.startsWith('quote')) {
state.visibleColumns.value = quoteColumnDefs.map((d) => d.prop)
state.currentQuery = computed(() =>
buildMobileQuoteQuery({
mode: 'manage',
view: state.view.value,
query: state.query.value,
visible: state.visibleColumns.value,
keyword: state.keyword.value,
currentUserId: 'user-a',
pageNum: 1,
pageSize: 20
})
)
}
if (file.startsWith('contract')) {
state.currentQuery = computed(() =>
buildMobileContractQuery({
mode: 'manage',
view: state.view.value,
query: state.query.value,
keyword: state.keyword.value,
currentUserId: 'user-a',
pageNum: 1,
pageSize: 20
})
)
}
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, [])
})
}