| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933 |
- 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()
- })
- }
|