import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync, existsSync } from 'node:fs' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import * as vue from 'vue' import { useClientColumnFilters } from '../src/composables/useClientColumnFilters' import * as customerFilterDefinitions from '../src/views/crm/customer/dialogColumnFilterDefs' 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 i = 0; i < 20; i++) await Promise.resolve() } // Execute actual script-setup functions inside Vue's renderer; API responses alone are deferred. const helperPath = new URL('../src/utils/mp-customer-panel-identity.ts', import.meta.url) function evaluate(page: string, props: any, overrides: Record, events: any[]) { const source = parse( readFileSync(new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8') ).descriptor.scriptSetup!.content const ast = ts.createSourceFile('panel.ts', source, ts.ScriptTarget.Latest, true) const vueEntries = Object.entries(vue).filter( ([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default' ) const bindings: Record = Object.fromEntries(vueEntries) delete bindings.default for (const statement of ast.statements) { if (!ts.isImportDeclaration(statement)) continue const clause = statement.importClause if (clause?.name) bindings[clause.name.text] = () => undefined if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined } if (existsSync(helperPath)) { const helper = ts.transpile( readFileSync(helperPath, 'utf8').replace('export function', 'function'), { target: ts.ScriptTarget.ESNext } ) bindings.useCustomerPanelIdentity = new Function( ...vueEntries.map(([k]) => k), `${helper};return useCustomerPanelIdentity` )(...vueEntries.map(([, v]) => v)) } Object.assign(bindings, { useClientColumnFilters, ...customerFilterDefinitions, defineProps: () => props, defineEmits: () => (...args: any[]) => events.push(args), defineExpose() {}, useDictStore: () => ({ getOptions: () => [], getLabel: () => '', getTagType: () => '' }), unwrapRows: (v: any) => (Array.isArray(v) ? v : v?.data?.rows || v?.data || v?.rows || []), unwrapData: (v: any) => v?.data || v, canDeleteByCreator: () => true, contactFields: [{ key: 'contactName', required: true }], requiredFieldsError: () => '', contactDetailsError: () => '', showCrmConfirmDialog: async () => {}, fetchContactByCustomerId: async () => [], fetchContactDetail: async (v: any) => v, ...overrides }) const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s)) const names = statements.flatMap((s) => ts.isFunctionDeclaration(s) ? [s.name!.text] : ts.isVariableStatement(s) ? s.declarationList.declarations.map((d) => d.name.getText(ast)) : [] ) const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), { target: ts.ScriptTarget.ESNext }) return new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)( ...Object.values(bindings) ) } const detailRoute = (id = 'A', tab = 'basic') => ({ name: 'MpCrmCustomerDetail', path: `/m/crm/customer/${id}`, params: { id }, query: { tab } }) function mount(initial = detailRoute(), withChild = false) { const current = vue.shallowRef(initial) const route = Object.fromEntries(['name', 'path', 'params', 'query'].map((key) => [key, null])) for (const key of Object.keys(route)) Object.defineProperty(route, key, { get: () => current.value[key] }) const reads: any[] = [], writes: any[] = [], messages: any[] = [], navigation: any[] = [] const confirm = deferred() let api: any let child: any const childRead = deferred() const childWrites: any[] = [] const renderer = vue.createRenderer({ patchProp() {}, insert() {}, remove() {}, createElement: () => ({}), createText: () => ({}), createComment: () => ({}), setText() {}, setElementText() {}, parentNode: () => null, nextSibling: () => null }) const Child = vue.defineComponent({ props: ['customerId', 'getIdentity'], setup(props) { child = evaluate( 'ContactPanel', props, { fetchContactByCustomerId: () => childRead.promise, showCrmConfirmDialog: () => confirm.promise, fetchDeleteContact: async (value: any) => childWrites.push(value) }, [] ) return () => vue.h('div') } }) const app = renderer.createApp({ setup() { api = evaluate( 'detail', {}, { useRoute: () => route, useRouter: () => ({ replace: (v: any) => navigation.push(v), push: (v: any) => navigation.push(v) }), fetchCustomerDetail: (params: any) => { const d = deferred() reads.push({ ...d, params }) return d.promise }, fetchDeleteCustomer: (params: any) => { const d = deferred() writes.push({ ...d, params }) return d.promise }, showCrmConfirmDialog: () => confirm.promise, customerForm: (v: any) => v, showFailToast: (v: any) => messages.push(v), showSuccessToast: (v: any) => messages.push(v) }, [] ) return () => withChild && api.customer.value && !api.loading.value ? vue.h(Child, { customerId: api.id.value, getIdentity: api.getPanelIdentity }) : vue.h('div') } }) app.mount({}) return { api, reads, writes, messages, navigation, confirm, childRead, childWrites, get child() { return child }, go: (value: any) => { current.value = value }, unmount: () => app.unmount() } } const destinations = [ { name: 'MpCrmCustomerList', path: '/m/crm/customer', params: {}, query: {} }, { name: 'MpCrmProductDetail', path: '/m/crm/product/B', params: { id: 'B' }, query: {} }, { name: 'MpCrmProductDetail', path: '/m/crm/product/A', params: { id: 'A' }, query: {} }, { name: 'MpCrmCustomerEdit', path: '/m/crm/customer/A/edit', params: { id: 'A' }, query: {} }, detailRoute('') ] for (const destination of destinations) { test(`leave to ${destination.name}/${destination.params.id || 'empty'} blocks all reads and invalidates synchronously`, async () => { const h = mount() h.api.onReady() const key = h.api.getPanelIdentity() h.go(destination) assert.notEqual(h.api.getPanelIdentity(), key) assert.equal(h.reads.length, 1, 'leaving must not request a customer') await h.api.load() await h.api.load('A') await h.api.refreshInfo() h.api.onReady() assert.equal(h.reads.length, 1) h.reads[0].resolve({ id: 'A', customerName: 'stale' }) await flush() assert.equal(h.api.customer.value, undefined) assert.equal(h.api.error.value, '') assert.deepEqual(h.messages, []) h.unmount() }) } test('ready gate, same-ID tab/query changes, retries and same-path reloads', async () => { const h = mount() await h.api.load() await h.api.refreshInfo() h.go(detailRoute('B')) assert.equal(h.reads.length, 0) h.api.onReady() h.api.onReady() assert.equal(h.reads.length, 1) assert.equal(h.reads[0].params.id, 'B') h.go(detailRoute('B', 'contact')) assert.equal(h.api.tab.value, 'contact') assert.equal(h.reads.length, 1) h.reads[0].reject(new Error('retryable')) await flush() assert.equal(h.api.error.value, 'retryable') const retry = h.api.load() h.reads[1].resolve({ id: 'B' }) await retry const reload = h.api.load() h.reads[2].resolve({ id: 'B' }) await reload const refresh = h.api.refreshInfo() h.reads[3].resolve({ id: 'B', customerName: 'fresh' }) await refresh assert.equal(h.api.customer.value.customerName, 'fresh') h.unmount() }) for (const aba of [false, true]) for (const rejectOld of [false, true]) { test(`latest route wins: ABA=${aba} reject=${rejectOld}`, async () => { const h = mount() h.api.onReady() h.go(detailRoute('B')) if (aba) h.go(detailRoute('A')) const latest = h.reads.at(-1) for (const read of h.reads.slice(0, -1)) { if (rejectOld) read.reject(new Error('old failure')) else read.resolve({ id: read.params.id, customerName: 'stale' }) } await flush() assert.equal(h.api.loading.value, true) assert.equal(h.api.error.value, '') assert.equal(h.api.customer.value, undefined) latest.resolve({ id: latest.params.id, customerName: 'latest' }) await flush() assert.equal(h.api.customer.value.customerName, 'latest') h.unmount() }) } test('customer detail has no deletion entry and leaving prevents stale edit navigation', async () => { const h = mount() h.api.onReady() h.reads[0].resolve({ id: 'A' }) await flush() assert.equal(h.api.remove, undefined) h.go(destinations[2]) h.api.goEdit() assert.deepEqual(h.writes, []) assert.deepEqual(h.navigation, []) h.unmount() }) for (const destination of destinations) test(`late ready outside valid detail: ${destination.name}/${destination.params.id || 'empty'}`, async () => { const h = mount() h.go(destination) h.api.onReady() assert.equal(h.reads.length, 0) h.unmount() }) test('refresh failure after same-ID leave is silent; reentry can load', async () => { const h = mount() h.api.onReady() h.reads[0].resolve({ id: 'A' }) await flush() const refresh = h.api.refreshInfo() h.go(destinations[2]) h.reads[1].reject(new Error('old refresh')) await refresh assert.deepEqual(h.messages, []) h.go(detailRoute()) assert.equal(h.reads.length, 3) h.reads[2].resolve({ id: 'A', customerName: 'back' }) await flush() assert.equal(h.api.customer.value.customerName, 'back') h.unmount() }) test('same-ID leave invalidates real child panel reads and confirmations before unmount', async () => { const h = mount(detailRoute(), true) h.api.onReady() h.reads[0].resolve({ id: 'A' }) await flush() const child = h.child const pending = child.remove({ id: 'contact-A' }) h.confirm.resolve() h.childRead.resolve([{ id: 'stale' }]) h.go(destinations[2]) assert.deepEqual(child.rows.value, []) assert.equal(child.acting.value, false) await pending await flush() assert.deepEqual(h.childWrites, []) assert.deepEqual(child.rows.value, []) h.unmount() }) test('unmount makes pending reads and all entry points inert', async () => { const h = mount() h.api.onReady() h.unmount() h.reads[0].reject(new Error('late')) await flush() await h.api.load() await h.api.refreshInfo() h.api.onReady() assert.equal(h.reads.length, 1) assert.equal(h.api.error.value, '') assert.deepEqual(h.messages, []) })