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

934 lines
32 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'
import * as vue from 'vue'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
import { plPlanColumnFilterDefs } from '../src/views/pl/plan/columnFilterDefs'
import { plPlanListTabToFilters } from '../src/utils/pl-display'
import {
applyMergedCustomerQueryParams,
applyContractSideQueryParams
} from '../src/utils/pl-plan-pair-list-query'
const root = new URL('../src/views/m/pl/', import.meta.url)
function harness(file: string) {
const requests: any[] = []
const state: any = {
userStore: reactive({
accessToken: 'session-a',
getUserInfo: { tenantNo: 'tenant-a', roles: ['sales'] }
}),
activeTab: ref('all'),
dateRange: ref([]),
plPlanListTabToFilters,
...useServerColumnFilters(plPlanColumnFilterDefs),
sort: reactive({ sortField: '', sortOrder: 'desc' }),
applyMergedCustomerQueryParams,
applyContractSideQueryParams,
keyword: ref('a'),
appliedKeyword: ref('a'),
page: ref(1),
plans: ref([{ id: 'existing' }]),
loading: ref(false),
total: ref(50),
error: ref(''),
forbidden: ref(false),
failedMore: ref(false),
selected: ref(new Set()),
expandedIds: ref(new Set()),
pageSize: 20,
planDetails: { reset() {} },
planApi: {
list: (params: any) =>
new Promise((resolve, reject) => requests.push({ params, resolve, reject }))
},
HttpError: Error,
ApiStatus: { forbidden: 403 }
}
state.query = () => ({ q: state.appliedKeyword.value })
const source = ts.createSourceFile(
file,
parse(readFileSync(new URL(file, root), 'utf8')).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const fn = file.startsWith('plan/') ? 'fetchPage' : 'load'
const declarations = source.statements.filter(
(n) =>
(ts.isFunctionDeclaration(n) &&
[fn, 'requestKey', ...(file.startsWith('plan/') ? ['buildParams'] : [])].includes(
n.name?.text || ''
)) ||
(ts.isVariableStatement(n) &&
n.declarationList.declarations.some((d) => d.name.getText(source) === 'sessionScope'))
)
const code = ts.transpile(declarations.map((n) => n.getText(source)).join('\n'), {
target: ts.ScriptTarget.ESNext
})
const api = new Function(
...Object.keys(state),
`let requestVersion=0; let disposed=false; ${code}; return { run:${fn}, dispose:()=>{disposed=true; requestVersion++} }`
)(...Object.values(state))
return {
state,
requests,
dispose: api.dispose,
run: (reset: boolean) => api.run(fn === 'load' ? !reset : reset)
}
}
for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
test(`${file}: latest search wins including stale failure and finally`, async () => {
const h = harness(file)
const old = h.run(true)
h.state.keyword.value = h.state.appliedKeyword.value = 'new'
const fresh = h.run(true)
h.requests[0].reject(new Error('old failure'))
await old
assert.equal(h.state.loading.value, true)
assert.equal(h.state.error.value, '')
h.requests[1].resolve({ list: [{ id: 'new' }], total: 1 })
assert.equal(await fresh, true)
assert.deepEqual(h.state.plans.value, [{ id: 'new' }])
})
test(`${file}: typing before debounce invalidates response, clear wins`, async () => {
const h = harness(file)
const old = h.run(true)
h.state.keyword.value = ''
h.requests[0].resolve({ list: [{ id: 'stale' }], total: 1 })
await old
assert.deepEqual(h.state.plans.value, [])
h.state.appliedKeyword.value = ''
const fresh = h.run(true)
h.requests[1].resolve({ list: [{ id: 'clear' }], total: 1 })
await fresh
assert.deepEqual(h.state.plans.value, [{ id: 'clear' }])
})
test(`${file}: failed page retries same page, concurrent more is ignored, rows dedupe`, async () => {
const h = harness(file)
const failed = h.run(false)
void h.run(false)
assert.equal(h.requests.length, 1)
assert.equal(h.requests[0].params.page, 2)
h.requests[0].reject(new Error('offline'))
await failed
assert.equal(h.state.page.value, 1)
assert.ok(h.state.error.value)
const retry = h.run(false)
assert.equal(h.requests[1].params.page, 2)
h.requests[1].resolve({ list: [{ id: 'existing' }, { id: 'next' }], total: 50 })
await retry
assert.equal(h.state.page.value, 2)
assert.deepEqual(
h.state.plans.value.map((p: any) => p.id),
['existing', 'next']
)
})
test(`${file}: unmount suppresses success and late debounce cannot request`, async () => {
const h = harness(file)
const pending = h.run(true)
h.dispose()
h.requests[0].resolve({ list: [{ id: 'late' }], total: 1 })
await pending
void h.run(true)
assert.deepEqual(h.state.plans.value, [])
assert.equal(h.requests.length, 1)
})
}
function actionHarness(file: string, overrides: any = {}) {
const userStore = reactive({
accessToken: 'session-a',
getUserInfo: { tenantNo: 'tenant-a', roles: ['sales'] }
})
const events: string[] = []
let confirmResolve: () => void = () => {}
const confirm = new Promise<void>((resolve) => {
confirmResolve = resolve
})
const plan = { id: 'p1', products: [] }
const state: any = {
acting: ref(false),
canReady: ref(true),
morePlan: ref(plan),
moreOpen: ref(true),
voidTarget: ref(plan),
voidOpen: ref(true),
voidReason: ref('original reason'),
userStore,
canWaitNotice: () => true,
canCancelWaitNotice: () => true,
canVoidPlan: () => true,
readyTargets: (rows: any[]) => rows,
refreshSidebarBadges: async () => {},
planHasHazardProducts: () => true,
HAZARD_CERT_READY_CONFIRM_HINT: '',
showCrmConfirmDialog: () => {
events.push('confirm')
return confirm
},
planApi: {
get: async (id: string) => ({ id, products: [] }),
setHazardCertReady: async (id: string) => {
events.push(`ready:${id}`)
},
cancel: async (id: string, data: any) => {
events.push(`void:${id}:${data.cancelReason}`)
},
waitNotice: async (id: string) => {
events.push(`wait:${id}`)
},
cancelWaitNotice: async (id: string) => {
events.push(`cancel-wait:${id}`)
}
},
ElMessage: Object.assign(() => events.push('toast'), {
warning: () => events.push('toast'),
success: () => events.push('toast'),
error: () => events.push('toast')
}),
load: async () => {
events.push('reload')
},
reload: () => {
events.push('reload')
}
}
const source = ts.createSourceFile(
file,
parse(readFileSync(new URL(file, root), 'utf8')).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
Object.assign(state.planApi, overrides.planApi || {})
const names = ['setReady', 'runMoreAction', 'confirmVoidPlan']
const code = ts.transpile(
source.statements
.filter(
(n) =>
(ts.isFunctionDeclaration(n) && names.includes(n.name?.text || '')) ||
(ts.isVariableStatement(n) &&
n.declarationList.declarations.some((d) => d.name.getText(source) === 'sessionScope'))
)
.map((n) => n.getText(source))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const api = new Function(
...Object.keys(state),
`let disposed=false; let actionVersion=0; ${code}; return { run: ${file.startsWith('plan-cert') ? '(row={id:"p1"})=>setReady(typeof row==="object"?row:{id:"p1"},true)' : '(key)=>key==="void" ? confirmVoidPlan() : runMoreAction(key)'}, dispose:()=>{disposed=true;actionVersion++}, invalidate:()=>{actionVersion++;acting.value=false} }`
)(...Object.values(state))
return {
state,
events,
confirmResolve,
...api,
changeScope: () => {
userStore.accessToken = 'session-b'
}
}
}
for (const kind of ['ready', 'wait', 'cancel-wait', 'void']) {
for (const invalidation of ['dispose', 'scope', 'session']) {
test(`${kind}: confirmation followed by ${invalidation} in same tick does not write`, async () => {
const h = actionHarness(kind === 'ready' ? 'plan-cert/index.vue' : 'plan/index.vue')
const pending = h.run(kind)
for (let n = 0; n < 5; n++) await Promise.resolve()
assert.ok(h.events.includes('confirm'))
h.confirmResolve()
if (invalidation === 'dispose') h.dispose()
if (invalidation === 'scope') h.changeScope()
if (invalidation === 'session') h.invalidate()
await pending
assert.deepEqual(h.events, ['confirm'])
})
}
test(`${kind}: duplicate confirmation is locked before dialog, captured target/reason is written once`, async () => {
const h = actionHarness(kind === 'ready' ? 'plan-cert/index.vue' : 'plan/index.vue')
const pending = h.run(kind)
for (let n = 0; n < 5; n++) await Promise.resolve()
const duplicate = h.run(kind)
h.state.voidReason.value = 'changed'
h.confirmResolve()
await Promise.all([pending, duplicate])
assert.equal(h.events.filter((e: string) => e === 'confirm').length, 1)
assert.equal(h.events.filter((e: string) => e.startsWith(`${kind}:`)).length, 1)
if (kind === 'void') assert.ok(h.events.includes('void:p1:original reason'))
})
}
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: [],
plans: [],
planRows: [],
visibleCards: [],
PL_PLAN_LIST_TABS: [],
PL_PLAN_CERT_TABS: [],
moreActions: [],
planDateValue: [],
selected: new Set(),
expandedIds: new Set(),
products: [],
cards: [],
views: [],
viewTabs: [],
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 === 'CertFilters'
? '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 file of ['plan/index.vue', 'plan-cert/index.vue']) {
test(`${file}: header input survives reload, empty result, error and retry`, async () => {
const { state, find, app } = mountTemplate(file)
const input = find('input')
assert.ok(input)
for (const phase of [
{ loading: true },
{ loading: false },
{ loading: false, error: 'offline' },
{ loading: true, error: '' }
]) {
Object.assign(state, phase)
await nextTick()
assert.equal(find('input'), input)
if (phase.loading) assert.ok(find('skeleton'))
}
app.unmount()
})
}
function returnStateHarness(base = '/m/pl/plan') {
let path = base
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('../crm/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({
isDetailPath: (target: string) => target.startsWith(`${base}/`) && target !== `${base}/new`,
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: `${base}/customer-2` })
navigate(`${base}/customer-2`)
unmount()
}
return { create, navigate, openDetail, user, scrolls }
}
for (const base of ['/m/pl/plan', '/m/pl/plan-cert']) {
test(`${base}: details restore filters/pages/expanded/scroll with no row cache`, async () => {
const env = returnStateHarness(base)
const list = env.create()
list.keyword.value = '测试计划'
list.query.value = { date: '2026-10-21', ready: '0', tab: 'all' }
env.openDetail()
env.navigate(base)
const returned = env.create()
await returned.state.restore()
assert.equal(returned.keyword.value, '测试计划')
assert.deepEqual(returned.query.value, { date: '2026-10-21', ready: '0', tab: 'all' })
assert.deepEqual(returned.calls, [1, 2, 3])
assert.deepEqual([...returned.expandedIds.value], ['customer-2'])
assert.deepEqual(env.scrolls, [720])
})
for (const change of ['session', 'tenant', 'outside', 'path']) {
test(`${base}: ${change} cannot reuse a private return state`, async () => {
const env = returnStateHarness(base)
env.create().keyword.value = 'private'
env.openDetail()
if (change === 'session') env.user.accessToken = 'session-b'
if (change === 'tenant') env.user.getUserInfo.tenantNo = 'tenant-b'
if (change === 'outside') env.navigate('/workbench')
env.navigate(change === 'path' ? '/m/other' : base)
const returned = env.create()
await returned.state.restore()
assert.equal(returned.keyword.value, '')
assert.deepEqual(returned.calls, [1])
assert.deepEqual(env.scrolls, [])
})
}
}
function deferred() {
let resolve!: (value?: any) => void
let reject!: (error: any) => void
const promise = new Promise<any>((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
const flush = async () => {
for (let n = 0; n < 20; n++) await Promise.resolve()
}
for (const response of [{ id: 'B', products: [] }, { products: [] }, null]) {
test(`ready: deferred GET A returning ${JSON.stringify(response)} cannot confirm or write another record`, async () => {
const get = deferred()
const h = actionHarness('plan-cert/index.vue', { planApi: { get: () => get.promise } })
h.confirmResolve()
const pending = h.run({ id: 'A' })
await flush()
assert.deepEqual(h.events, [])
get.resolve({ data: response })
await pending
assert.deepEqual(h.events, ['toast'])
assert.equal(h.state.acting.value, false)
})
}
for (const failure of ['get', 'write']) {
test(`ready: delayed ${failure} failure and revoked permission cannot show stale result or refresh`, async () => {
const pendingApi = deferred()
const gets: string[] = [],
writes: string[] = []
const h = actionHarness('plan-cert/index.vue', {
planApi: {
get: (id: string) => {
gets.push(id)
return failure === 'get' ? pendingApi.promise : Promise.resolve({ id, products: [] })
},
setHazardCertReady: (id: string) => {
writes.push(id)
return pendingApi.promise
}
}
})
h.confirmResolve()
const pending = h.run({ id: 'A' })
await flush()
h.state.canReady.value = false
pendingApi.reject(new Error('late failure'))
await pending
assert.deepEqual(gets, ['A'])
assert.deepEqual(writes, failure === 'get' ? [] : ['A'])
assert.deepEqual(h.events, failure === 'get' ? [] : ['confirm'])
})
}
for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
for (const outcome of ['success', 'error']) {
test(`${file}: actual requestKey and sessionScope discard old ${outcome} after tenant change`, async () => {
const h = harness(file)
const pending = h.run(true)
h.state.userStore.getUserInfo.tenantNo = 'tenant-b'
if (outcome === 'success') h.requests[0].resolve({ list: [{ id: 'private-A' }], total: 1 })
else h.requests[0].reject(new Error('old tenant failure'))
await pending
assert.deepEqual(h.state.plans.value, [])
assert.equal(h.state.error.value, '')
const next = h.run(true)
h.requests[1].resolve({ list: [{ id: 'tenant-B' }], total: 1 })
await next
assert.deepEqual(h.state.plans.value, [{ id: 'tenant-B' }])
assert.equal(h.state.loading.value, false)
})
}
}
function copySessionHarness() {
const clipboard = deferred()
const messages: string[] = []
let copies = 0
const userStore = reactive({
accessToken: 'session-a',
getUserInfo: { tenantNo: 'tenant-a', roles: ['sales'] }
})
const planApi = {
get: async (id: string) => ({ id, transportStatus: 'arranged' }),
getArrangeInfo: async () => ({ driverUrl: 'driver-A' })
}
const composableText = readFileSync(
new URL('../src/composables/usePlPlanArrangeActions.ts', import.meta.url),
'utf8'
)
const composable = ts.createSourceFile('actions.ts', composableText, ts.ScriptTarget.Latest, true)
const code = ts.transpile(
composable.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(composable))
.join('\n')
.replace(/export /g, ''),
{ target: ts.ScriptTarget.ESNext }
)
const env: any = {
...Object.fromEntries(
Object.entries(vue).filter(
([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && !['version', 'default'].includes(k)
)
),
planApi,
userStore,
pickBxData: (r: any) => r.data ?? r,
useDictStore: () => ({ getLabel: () => '' }),
ElMessage: {
success: (m: string) => messages.push(m),
warning: (m: string) => messages.push(m),
error: (m: string) => messages.push(m)
},
copyTextToClipboard: () => {
copies++
return clipboard.promise
},
buildDriverSummaryFromPlan: () => 'driver summary',
buildDriverStopBlocksFromPlan: () => [],
buildPoundQrcodeLinksFromInventoryRecords: () => []
}
const actions = new Function(...Object.keys(env), `${code};return usePlPlanArrangeActions`)(
...Object.values(env)
)({ isolateRecord: true })
Object.assign(env, actions, {
morePlan: ref({ id: 'A' }),
moreOpen: ref(true),
voidOpen: ref(false),
voidTarget: ref(null),
acting: ref(false),
plans: ref([{ id: 'private-A' }]),
expandedIds: ref(new Set(['A'])),
page: ref(1),
total: ref(1),
loading: ref(true),
error: ref(''),
forbidden: ref(false),
speedOpen: ref(false),
filterOpen: ref(true),
fetchPage: (reset: boolean) => {
env.reloads.push(reset)
},
reloads: []
})
const ast = ts.createSourceFile(
'page.ts',
parse(readFileSync(new URL('plan/index.vue', root), 'utf8')).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const pageCode = ts.transpile(
ast.statements
.filter(
(s) =>
(ts.isFunctionDeclaration(s) && s.name?.text === 'runMoreAction') ||
(ts.isVariableStatement(s) &&
s.declarationList.declarations.some((d) => d.name.getText(ast) === 'sessionScope')) ||
(ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
s.expression.expression.getText(ast) === 'watch' &&
s.expression.arguments[0]?.getText(ast) === 'sessionScope')
)
.map((s) => s.getText(ast))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const scope = vue.effectScope()
const api = scope.run(() =>
new Function(
...Object.keys(env),
`let disposed=false;let shellReady=true;let actionVersion=0;let requestVersion=0;${pageCode};return {run:()=>runMoreAction('copy-driver'),version:()=>requestVersion}`
)(...Object.values(env))
)!
return {
...api,
env,
actions,
clipboard,
messages,
userStore,
copies: () => copies,
stop: () => scope.stop()
}
}
for (const change of ['token', 'tenant', 'roles']) {
test(`transport: actual page scope watcher resets real copy composable during clipboard pending (${change})`, async () => {
const h = copySessionHarness()
const pending = h.run()
await flush()
assert.equal(h.copies(), 1)
if (change === 'token') h.userStore.accessToken = 'session-b'
if (change === 'tenant') h.userStore.getUserInfo.tenantNo = 'tenant-b'
if (change === 'roles') h.userStore.getUserInfo.roles = ['viewer']
assert.equal(h.actions.plan.value, null, 'copy record must invalidate synchronously with scope')
assert.equal(h.version(), 1, 'list request generation also invalidates')
assert.deepEqual(h.env.plans.value, [])
assert.equal(h.env.filterOpen.value, false, 'the old account filter sheet closes')
assert.deepEqual(h.env.reloads, [true], 'the new account reloads the first page')
h.env.acting.value = true // A new session owns the busy indicator now.
h.clipboard.resolve(true)
await pending
assert.deepEqual(h.messages, [])
assert.equal(h.env.acting.value, true, 'old finally cannot clear the new busy indicator')
h.stop()
})
}
// Execute the production setup and Vue watchers, stubbing only external services/hooks.
function certSessionHarness() {
const lists: any[] = [],
permissions: any[] = [],
gets: any[] = [],
writes: any[] = []
const events: string[] = []
const user = reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a', roles: ['sales'] } })
const queue = (items: any[], params?: any) => {
const request = deferred()
items.push({ ...request, params })
return request.promise
}
const env = {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
useUserStore: () => user,
useRouter: () => ({ push: (path: string) => events.push(path) }),
useMenuStore: () => ({ refreshSidebarBadges: async () => {} }),
onBeforeUnmount: () => {},
useDebounceFn: (fn: any) => fn,
useIntersectionObserver: () => {},
useListReturnState: () => ({ restoring: ref(false), restore: () => events.push('restore') }),
useMobilePlanListDetails: () => ({ reset() {}, ensure: async () => true, records: {} }),
PL_PLAN_DEFAULT_FOLLOW_STATUSES: ['pending'],
plPlanCertColumnFilterDefs: [],
useServerColumnFilters: (_defs: any, options: any) => {
const filters = reactive(options.initialFilters)
return { filters, toQueryParams: () => ({ ...filters }) }
},
buildQuery: (tab: string, filters: any, keyword: string, dates: any) => ({
tab,
filters,
keyword,
dates
}),
applyMergedCustomerQueryParams: () => {},
applyContractSideQueryParams: () => {},
canEdit: () => true,
readyTargets: (rows: any[]) => rows,
planHasHazardProducts: () => true,
HAZARD_CERT_READY_CONFIRM_HINT: '',
showCrmConfirmDialog: async () => {
events.push('confirm')
},
ElMessage: Object.assign(() => events.push('toast'), { warning: () => events.push('toast') }),
planApi: {
list: (params: any) => queue(lists, params),
get: (id: string) => queue(gets, id),
setHazardCertReady: (id: string) => queue(writes, id)
},
fetchGetMenuCodes: () => queue(permissions)
}
const source = ts.createSourceFile(
'page.ts',
parse(readFileSync(new URL('plan-cert/index.vue', root), 'utf8')).descriptor.scriptSetup!
.content,
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const scope = vue.effectScope()
const state = scope.run(() =>
new Function(
...Object.keys(env),
`${code}; return { load, loadPermissions, setReady, saved, openUpload, userStore, plans, expandedIds, total, page, loading, acting, error, failedMore, canUpload, canReady, canPrice, permissionsLoaded, permissionError, uploadOpen, uploadId, keyword, appliedKeyword, dates, dateKind, readyKind, tab, filters, sort, filtersOpen, ${code.includes('function reloadSession') ? 'reloadSession, sessionChanged, uploadRef,' : ''} }`
)(...Object.values(env))
)
return { state, user, lists, permissions, gets, writes, events, stop: () => scope.stop() }
}
for (const change of ['token', 'tenant', 'roles']) {
test(`cert session: ${change} synchronously clears private state and recovers without old callbacks`, async () => {
const h = certSessionHarness(),
s = h.state
s.plans.value = [{ id: 'private' }]
s.loading.value = false
s.total.value = 40
s.page.value = 2
s.expandedIds.value = new Set(['private'])
s.keyword.value = s.appliedKeyword.value = 'private keyword'
s.filters.customer = { text: 'private customer' }
s.filtersOpen.value = true
const oldPermission = s.loadPermissions()
s.canUpload.value = s.canReady.value = s.canPrice.value = true
s.openUpload({ id: 'private' })
let invalidations = 0
if (s.uploadRef) s.uploadRef.value = { invalidateRecord: () => invalidations++ }
const oldAction = s.setReady({ id: 'private' }, true)
const oldList = s.load(true)
h.user.getUserInfo = { tenantNo: 'a', roles: ['sales'] }
assert.equal(s.loading.value, true, 'same-content user info does not invalidate')
if (change === 'token') h.user.accessToken = 'b'
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
if (change === 'roles') h.user.getUserInfo.roles = ['viewer']
assert.deepEqual(s.plans.value, [], 'old tenant rows must clear synchronously')
assert.equal(s.loading.value, false)
assert.equal(s.acting.value, false)
for (const name of ['canUpload', 'canReady', 'canPrice', 'uploadOpen', 'filtersOpen'])
assert.equal(s[name].value, false)
assert.equal(s.uploadId.value, '')
assert.equal(invalidations, 1, 'child must invalidate before Vue propagates props')
assert.equal(s.total.value, 0)
assert.equal(s.page.value, 1)
assert.equal(s.expandedIds.value.size, 0)
assert.equal(s.keyword.value + s.appliedKeyword.value, '')
assert.equal(s.filters.customer, undefined)
assert.equal(s.sessionChanged.value, true)
await s.saved('private')
assert.equal(h.lists.length, 1, 'late upload success cannot trigger reload')
const recovery = s.reloadSession()
assert.equal(h.lists[1].params.page, 1)
assert.equal(h.lists[1].params.keyword, '')
h.permissions[0].resolve({ all: true })
h.lists[0].reject(new Error('old tenant failed'))
h.gets[0].resolve({ id: 'private', products: [] })
await Promise.all([oldPermission, oldList, oldAction])
assert.equal(s.loading.value, true, 'old finally cannot release fresh load')
assert.equal(s.canUpload.value, false)
assert.equal(s.error.value, '')
assert.deepEqual(h.events, [])
h.permissions[1].resolve({ menuCodes: ['pl:plan-cert:upload'] })
h.lists[1].resolve({ list: [{ id: 'fresh' }], total: 1 })
await recovery
assert.deepEqual(s.plans.value, [{ id: 'fresh' }])
assert.equal(s.canUpload.value, true)
assert.equal(s.canReady.value, false)
assert.equal(s.loading.value, false)
h.stop()
})
}
for (const outcome of ['success', 'failure']) {
test(`cert session: late ${outcome} write cannot release a new action or reload`, async () => {
const h = certSessionHarness(),
s = h.state
s.canReady.value = true
const old = s.setReady({ id: 'private' }, true)
h.gets[0].resolve({ id: 'private', products: [] })
await flush()
assert.equal(h.writes.length, 1)
h.user.accessToken = 'b'
s.canReady.value = true
const fresh = s.setReady({ id: 'fresh' }, true)
if (outcome === 'success') h.writes[0].resolve()
else h.writes[0].reject(new Error('late write failure'))
await old
assert.equal(s.acting.value, true)
assert.equal(h.lists.length, 0)
assert.deepEqual(h.events, ['confirm'])
h.gets[1].resolve({ id: 'fresh', products: [] })
await flush()
h.writes[1].resolve()
await flush()
assert.equal(h.lists.length, 1)
h.lists[0].resolve({ list: [{ id: 'fresh' }], total: 1 })
await fresh
assert.equal(s.acting.value, false)
h.stop()
})
}
for (const changed of [false, true]) {
test(`cert session: upload success ${changed ? 'cannot navigate after scope change during refresh' : 'navigates after normal child close'}`, async () => {
const h = certSessionHarness(),
s = h.state
s.canUpload.value = true
s.openUpload({ id: 'target' })
s.uploadOpen.value = false // The child closes its v-model before emitting success.
const saved = s.saved('target')
assert.equal(h.lists.length, 1)
if (changed) h.user.accessToken = 'b'
h.lists[0].resolve({ list: [{ id: 'target' }], total: 1 })
await saved
assert.deepEqual(h.events, changed ? [] : ['/m/pl/plan-cert/target'])
assert.deepEqual(s.plans.value, changed ? [] : [{ id: 'target' }])
h.stop()
})
}