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((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 } const node = (type: string): Node => ({ type, children: [], props: {}, style: {}, addEventListener() {}, removeEventListener() {} }) as Node const renderer = createRenderer({ 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>({ 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>({}) 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((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() }) }