| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- 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, [])
- })
- }
|