import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import * as vue from 'vue' 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() } function harness(page: string, overrides: Record = {}) { const content = parse( readFileSync(new URL(`../src/views/m/crm/${page}.vue`, import.meta.url), 'utf8') ).descriptor.scriptSetup!.content const ast = ts.createSourceFile('page.ts', content, ts.ScriptTarget.Latest, true) const route = vue.reactive({ name: page === 'customer/detail' ? 'MpCrmCustomerDetail' : page === 'quote/detail' ? 'MpCrmQuoteDetail' : page === 'contract/detail' ? 'MpCrmContractDetail' : undefined, params: { id: 'A' }, query: {} as Record, fullPath: '/A', get path() { return page === 'quote/detail' || page === 'contract/detail' ? `/m/crm/${page.split('/')[0]}/${this.params.id}` : '/A' } }) const unmounts: Array<() => void> = [] const writes: any[] = [], pushes: any[] = [], calls: string[] = [] const bindings: Record = Object.fromEntries( Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') ) for (const s of ast.statements) { if (!ts.isImportDeclaration(s)) continue const clause = s.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 } const read = async (arg: any) => { const id = typeof arg === 'string' ? arg : arg.id calls.push(id) return { data: { id, status: 'draft', products: [], productList: [] } } } Object.assign(bindings, { useRoute: () => route, useRouter: () => ({ push: (v: any) => pushes.push(v), replace: async (v: any) => { if (typeof v === 'object') route.query = v.query else pushes.push(v) } }), useUserStore: () => ({ info: { id: 'user' } }), useDictStore: () => ({ getLabel: () => '', getTagType: () => '' }), onMounted() {}, onUnmounted: (fn: any) => unmounts.push(fn), onBeforeUnmount: (fn: any) => unmounts.push(fn), getContract: read, getQuote: read, fetchCustomerDetail: read, fetchProductDetail: read, listContractApproval: async () => ({ data: [] }), listApproval: async () => ({ data: [] }), listQuoteProduct: async () => ({ rows: [] }), fetchCategoryTree: async () => ({ data: [] }), buildPathLabelMapFromTreeRoots: () => new Map(), unwrapData: (v: any) => v.data ?? v, customerForm: (v: any) => v, canDeleteByCreator: () => true, isContractEditableStatus: () => true, ElMessage: { warning() {}, error() {}, success() {} }, showCrmConfirmDialog: async () => {}, showProductConfirmDialog: async () => {}, fetchDeleteCustomer: async (v: any) => writes.push(v), fetchDeleteProduct: async (v: any) => writes.push(v), delContract: async (v: any) => writes.push(v), delQuote: async (v: any) => writes.push(v), fetchUpdateProductStatus: async (v: any) => writes.push(v), defineExpose() {}, defineProps: () => vue.reactive({ modelValue: true, ids: ['A'], embedded: false }), withDefaults: (props: any) => props, defineEmits: () => (...v: any[]) => pushes.push(v), fetchIntoPool: async (v: any) => writes.push(v), document: { querySelector: () => null }, window: { removeEventListener() {}, scrollTo() {} }, ...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 }) const scope = vue.effectScope() const api: any = scope.run(() => new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)( ...Object.values(bindings) ) ) return { ...api, route, calls, writes, pushes, ready: api.onShellReady || api.onReady, record: api.contract || api.quote || api.customer || api.detail, unmount: () => { unmounts.forEach((fn) => fn()) scope.stop() } } } for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) { test(`${page}: ready gate, immediate identity reset and one read per ID`, async () => { const h = harness(page) h.route.params.id = 'B' await flush() assert.deepEqual(h.calls, []) h.ready() await flush() assert.deepEqual(h.calls, ['B']) h.ready() await flush() assert.deepEqual(h.calls, ['B']) h.route.params.id = 'C' assert.ok(!h.record.value) await flush() assert.deepEqual(h.calls, ['B', 'C']) h.route.query.tab = 'products' h.route.fullPath = '/C?tab=products' await flush() assert.deepEqual(h.calls, ['B', 'C']) h.unmount() }) test(`${page}: mismatched successful response is never actionable`, async () => { const bad = async () => ({ data: { id: 'WRONG' } }) const h = harness(page, { getContract: bad, getQuote: bad, fetchCustomerDetail: bad, fetchProductDetail: bad }) h.ready() await flush() assert.ok(!h.record.value) h.unmount() }) test(`${page}: confirmation from A cannot delete after switching`, async () => { const confirmation = deferred() const h = harness(page, { showCrmConfirmDialog: () => confirmation.promise, showProductConfirmDialog: () => confirmation.promise }) h.ready() await flush() const pending = (h.remove || h.onDelete)() h.route.params.id = 'B' await flush() confirmation.resolve() await pending assert.deepEqual(h.writes, []) h.unmount() }) } test('pool: changed targets during confirmation cannot submit', async () => { const confirmation = deferred(), props = vue.reactive({ modelValue: true, ids: ['A'] }) const h = harness('customer/CustomerPoolSheet', { defineProps: () => props, showCrmConfirmDialog: () => confirmation.promise }) h.reason.value = 'reason' const pending = h.submit() props.ids = ['B'] confirmation.resolve() await pending assert.deepEqual(h.writes, []) h.unmount() }) for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) { for (const outcome of ['success', 'failure', 'ABA', 'unmount']) { test(`${page}: delayed ${outcome} cannot publish into a newer session`, async () => { const old = deferred() let first = true const read = async (arg: any) => { const id = typeof arg === 'string' ? arg : arg.id if (first) { first = false return old.promise } return { data: { id, marker: 'fresh', status: 'draft' } } } const h = harness(page, { getContract: read, getQuote: read, fetchCustomerDetail: read, fetchProductDetail: read }) h.ready() await flush() if (outcome === 'unmount') h.unmount() else { h.route.params.id = 'B' await flush() assert.equal(h.record.value.id, 'B') if (outcome === 'ABA') { h.route.params.id = 'A' await flush() } } if (outcome === 'failure') old.reject(Error('old failure')) else old.resolve({ data: { id: 'A', marker: 'old' } }) await flush() if (outcome === 'unmount') assert.ok(!h.record.value) else { assert.equal(h.record.value.marker, 'fresh') assert.equal((h.errorText || h.error).value, '') assert.equal(h.loading.value, false) h.unmount() } }) } test(`${page}: old sent delete cannot navigate or clear new operation busy`, async () => { const sent = deferred(), fresh = deferred() let count = 0 const write = () => (++count === 1 ? sent.promise : fresh.promise) const h = harness(page, { delContract: write, delQuote: write, fetchDeleteCustomer: write, fetchDeleteProduct: write }) h.ready() await flush() const old = (h.remove || h.onDelete)() await flush() assert.equal(count, 1) h.route.params.id = 'B' await flush() const current = (h.remove || h.onDelete)() await flush() assert.equal(count, 2) sent.resolve() await old assert.deepEqual(h.pushes, []) assert.equal((h.acting || h.deleting).value, true) fresh.resolve() await current assert.equal(h.pushes.length, 1) h.unmount() }) } for (const page of ['contract/detail', 'quote/detail']) { test(`${page}: mode change invalidates confirmation and query-only changes preserve submit`, async () => { const confirmation = deferred() const h = harness(page, { showCrmConfirmDialog: () => confirmation.promise, fetchUserOptions: async () => [] }) h.ready() await flush() await h.openSubmitSheet() h.route.query.tab = 'products' h.route.fullPath = '/A?tab=products' await flush() assert.equal(h.submitOpen.value, true) const pending = h.onDelete() h.route.query.from = 'approve' assert.equal(h.submitOpen.value, false) confirmation.resolve() await pending assert.deepEqual(h.writes, []) await h.onDelete() assert.deepEqual(h.writes, []) assert.equal(h.calls.length, 1) h.unmount() }) test(`${page}: route action consumes own query once and preserves new sheet`, async () => { const h = harness(page, { fetchUserOptions: async () => [] }) h.route.query.action = 'submit' h.ready() await flush() assert.equal(h.route.query.action, undefined) assert.equal(h.submitOpen.value, true) assert.deepEqual(h.calls, ['A']) await h.runRouteAction() assert.equal(h.submitOpen.value, true) h.unmount() }) test(`${page}: route action awaiting replace cannot open on another record or mode`, async () => { const replacing = deferred() const h = harness(page, { useRouter: () => ({ replace: () => replacing.promise, push() {} }) }) h.ready() await flush() h.route.query.action = 'submit' const pending = h.runRouteAction() h.route.query.from = 'approve' replacing.resolve() await pending assert.equal(h.submitOpen.value, false) h.unmount() }) } for (const stage of ['products', 'enrichment', 'approval']) { test(`quote: delayed ${stage} publishes only local current-record values`, async () => { const delayed = deferred() const h = harness('quote/detail', { getQuote: async (id: string) => ({ data: { id, productList: stage === 'products' ? [] : [{ productId: id, name: id }] } }), listQuoteProduct: (id: string) => id === 'A' ? delayed.promise : Promise.resolve({ rows: [{ productId: id, name: id }] }), enrichQuoteDetailProductMeta: async (lines: any[]) => { if (stage === 'enrichment' && lines[0]?.productId === 'A') { await delayed.promise lines[0].name = 'stale' } }, listApproval: (id: string) => stage === 'approval' && id === 'A' ? delayed.promise : Promise.resolve({ data: [{ id }] }) }) h.ready() await flush() h.route.params.id = 'B' await flush() assert.equal(h.products.value[0].name, 'B') delayed.resolve({ rows: [{ name: 'stale' }], data: [{ id: 'stale' }] }) await flush() assert.equal(h.record.value.id, 'B') assert.equal(h.products.value[0].name, 'B') assert.equal(h.approvalList.value[0].id, 'B') h.unmount() }) } for (const timing of ['picker', 'first upload', 'sent attachment write']) { test(`contract: ${timing} session is invalidated by switching ID`, async () => { const delayed = deferred(), uploads: any[] = [], attachments: any[] = [] const h = harness('contract/detail', { parseCrmAttachmentField: () => [], buildAttachmentUploadFormData: (file: any) => file, CRM_UPLOAD_BIZ_TYPE: { CONTRACT_ATTACHMENT: 'contract' }, fetchUploadAttachment: (file: any) => { uploads.push(file) return timing === 'first upload' ? delayed.promise : Promise.resolve({ data: { fileUrl: file } }) }, uploadAttachments: (body: any) => { attachments.push(body) return delayed.promise } }) h.ready() await flush() let clicked = 0 h.fileInputRef.value = { click: () => clicked++ } await h.runAction('upload') assert.equal(clicked, 1) if (timing === 'picker') { h.route.params.id = 'B' await flush() } const pending = h.onFilesChosen({ target: { files: ['one', 'two'], value: 'selected' } }) await flush() if (timing !== 'picker') { h.route.params.id = 'B' await flush() } h.acting.value = true delayed.resolve({ data: { fileUrl: 'one' } }) await pending assert.equal(uploads.length, timing === 'picker' ? 0 : timing === 'first upload' ? 1 : 2) assert.equal(attachments.length, timing === 'sent attachment write' ? 1 : 0) assert.equal(h.record.value.id, 'B') assert.equal(h.acting.value, true) assert.deepEqual(h.calls, ['A', 'B']) h.unmount() }) } for (const invalidation of ['close', 'prop close', 'unmount', 'ABA']) { test(`pool: ${invalidation} invalidates waiting confirmation`, async () => { const confirmation = deferred(), props = vue.reactive({ modelValue: true, ids: ['A'] }) const h = harness('customer/CustomerPoolSheet', { defineProps: () => props, showCrmConfirmDialog: () => confirmation.promise }) h.reason.value = 'reason' const pending = h.submit() if (invalidation === 'close') h.close() if (invalidation === 'prop close') props.modelValue = false if (invalidation === 'unmount') h.unmount() if (invalidation === 'ABA') { props.ids = ['B'] props.ids = ['A'] } confirmation.resolve() await pending assert.deepEqual(h.writes, []) if (invalidation !== 'unmount') h.unmount() }) } test('pool: captured reason and ids are stable; sent stale result cannot emit success', async () => { const confirm = deferred(), sent = deferred(), writes: any[] = [] const props = vue.reactive({ modelValue: true, ids: ['A'] }) const h = harness('customer/CustomerPoolSheet', { defineProps: () => props, showCrmConfirmDialog: () => confirm.promise, fetchIntoPool: (body: any) => { writes.push(body) return sent.promise } }) h.reason.value = 'original' const pending = h.submit() h.reason.value = 'changed' confirm.resolve() await flush() assert.deepEqual(writes, [{ ids: ['A'], reason: 'original' }]) props.ids = ['B'] h.saving.value = true sent.resolve() await pending assert.equal(h.saving.value, true) assert.deepEqual(h.pushes, []) h.unmount() }) for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) { test(`${page}: confirmation rechecks loading before write`, async () => { const confirmation = deferred() const h = harness(page, { showCrmConfirmDialog: () => confirmation.promise, showProductConfirmDialog: () => confirmation.promise }) h.ready() await flush() const pending = (h.remove || h.onDelete)() h.loading.value = true confirmation.resolve() await pending assert.deepEqual(h.writes, []) h.unmount() }) } test('quote: approval writes the pending approval ID, never the quote ID', async () => { const writes: any[] = [] const h = harness('quote/detail', { getQuote: async (id: string) => ({ data: { id, status: 'submitted' } }), listApproval: async () => ({ data: [{ id: 'approval-record', approverId: 'user' }] }), findPendingQuoteApproval: (rows: any[]) => rows[0], approveQuote: async (body: any) => writes.push(body) }) h.route.query.from = 'approve' h.ready() await flush() await h.runAction('approve') await h.confirmApprove() assert.equal(writes.length, 1) assert.equal(writes[0].id, 'approval-record') assert.equal(h.acting.value, false) h.unmount() }) test('customer: direct edit requires loaded current identity', async () => { const h = harness('customer/detail') h.ready() await flush() h.customer.value = { id: 'WRONG' } h.goEdit() assert.deepEqual(h.pushes, []) h.customer.value = { id: 'A' } h.loading.value = true h.goEdit() assert.deepEqual(h.pushes, []) h.unmount() }) test('pool: equivalent ids replacement preserves reason and pending confirmation', async () => { const confirmation = deferred() const props = vue.reactive({ modelValue: true, ids: ['A'] }) const h = harness('customer/CustomerPoolSheet', { defineProps: () => props, showCrmConfirmDialog: () => confirmation.promise }) h.reason.value = '保留原因' const pending = h.submit() assert.equal(h.saving.value, true) props.ids = ['A'] assert.equal(h.reason.value, '保留原因') assert.equal(h.saving.value, true) confirmation.resolve() await pending assert.deepEqual(h.writes, [{ ids: ['A'], reason: '保留原因' }]) assert.equal(h.pushes.filter((event: any[]) => event[0] === 'success').length, 1) assert.equal(h.saving.value, false) h.unmount() })