| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527 |
- 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<any>((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<string, any> = {}) {
- 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<string, string>,
- 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<string, any> = 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()
- })
|