934 lines
32 KiB
TypeScript
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()
|
|
})
|
|
}
|