| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- import assert from 'node:assert/strict'
- import { readFileSync } from 'node:fs'
- import test from 'node:test'
- import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue'
- import * as Vue from 'vue'
- import { compileTemplate, parse } from '@vue/compiler-sfc'
- import ts from 'typescript'
- import { buildAttachmentUploadFormData } from '../src/utils/crm-upload'
- const file = new URL('../src/views/m/crm/customer/CustomerAttachments.vue', import.meta.url)
- const { descriptor } = parse(readFileSync(file, 'utf8'))
- const ast = ts.createSourceFile(
- 'CustomerAttachments.ts',
- descriptor.scriptSetup!.content,
- ts.ScriptTarget.Latest,
- true
- )
- // Run every setup statement, replacing only external imports and SFC macros.
- const source = ast.statements
- .filter((node) => !ts.isImportDeclaration(node))
- .map((node) => node.getText(ast))
- .join('\n')
- function harness(initialProps: Record<string, unknown> = {}) {
- let resolve!: (value: unknown) => void
- let reject!: (error: Error) => void
- const response = new Promise((done, fail) => {
- resolve = done
- reject = fail
- })
- const requests: FormData[] = []
- const events: unknown[][] = []
- const notices: string[] = []
- const previews: unknown[] = []
- const downloads: unknown[] = []
- let exposed: { hasUploading: () => boolean }
- const scope = effectScope()
- const bindings = {
- computed,
- nextTick,
- reactive,
- ref,
- watch,
- defineProps: () => initialProps,
- withDefaults: (props: object, defaults: object) => reactive({ ...defaults, ...props }),
- defineEmits:
- () =>
- (...args: unknown[]) =>
- events.push(args),
- defineExpose: (value: typeof exposed) => {
- exposed = value
- },
- showToast: (message: string) => notices.push(message),
- showFailToast: (message: string) => notices.push(message),
- buildAttachmentUploadFormData,
- fetchUploadAttachment: (data: FormData) => {
- requests.push(data)
- return response
- },
- // Only external URL resolution and navigation are stubbed; Vue state and FormData stay real.
- parseCrmAttachmentField: (value: string) => value.split(',').filter(Boolean),
- resolveCrmAttachmentFiles: (urls: string[]) =>
- urls.map((url) => ({ url, name: url.split('/').pop() })),
- resolveCrmAttachmentUrl: (url: string) => `https://files.example/${url}`,
- isCrmImageFile: ({ name }: { name: string }) => name.endsWith('.png'),
- openCrmAttachmentPreviewInNewTab: (item: unknown) => previews.push(item),
- downloadCrmAttachment: (item: unknown) => downloads.push(item)
- }
- const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext })
- const instance = scope.run(() =>
- new Function(
- ...Object.keys(bindings),
- `${code}\nreturn { props, onPick, gridItems, fileList, uploading, onPreview, onDownload, openPicker, onRemove, uploadOne, fileInputRef }`
- )(...Object.values(bindings))
- )
- return {
- ...instance,
- resolve,
- reject,
- requests,
- events,
- notices,
- previews,
- downloads,
- hasUploading: () => exposed.hasUploading(),
- stop: () => scope.stop()
- }
- }
- async function startUpload(h: ReturnType<typeof harness>) {
- const raw = new File(['fixture'], '客户附件.png', { type: 'image/png' })
- const input = { files: [raw], value: 'selected' }
- const pending = h.onPick({ target: input })
- await nextTick()
- // Prime the computed cache while transport is pending, just as the template does.
- assert.equal(h.gridItems.value[0].uploading, true)
- assert.equal(h.gridItems.value[0].url, '')
- assert.equal(h.uploading.value, 1)
- assert.equal(h.hasUploading(), true)
- assert.equal(input.value, '')
- assert.equal(h.requests.length, 1)
- assert.equal(h.requests[0].get('bizType'), 'crm_customer_certificate')
- assert.equal(h.requests[0].get('folder'), 'crm/customer')
- assert.equal((h.requests[0].get('file') as File).name, raw.name)
- return { pending }
- }
- test('pending customer upload refreshes the cached grid and enables preview on success', async () => {
- const h = harness()
- try {
- const { pending } = await startUpload(h)
- h.resolve({ data: { fileUrl: 'customer/saved.png' } })
- await pending
- await nextTick()
- assert.equal(h.gridItems.value[0].uploading, false)
- assert.equal(h.gridItems.value[0].url, 'https://files.example/customer/saved.png')
- assert.equal(h.fileList.value[0].status, 'done')
- assert.equal(h.uploading.value, 0)
- assert.equal(h.hasUploading(), false)
- assert.equal(h.requests.length, 1)
- assert.deepEqual(h.events, [['update:modelValue', 'customer/saved.png']])
- h.onPreview(h.gridItems.value[0])
- assert.deepEqual(h.previews, [{ ossId: 'customer/saved.png', name: '客户附件.png' }])
- assert.deepEqual(h.notices, [])
- } finally {
- h.stop()
- }
- })
- test('rejected customer upload clears the cached grid uploading state without emitting a URL', async () => {
- const h = harness()
- try {
- const { pending } = await startUpload(h)
- h.reject(new Error('fixture upload failure'))
- await pending
- await nextTick()
- assert.equal(h.gridItems.value[0].uploading, false)
- assert.equal(h.gridItems.value[0].url, '')
- assert.equal(h.fileList.value[0].status, 'failed')
- assert.equal(h.uploading.value, 0)
- assert.equal(h.hasUploading(), false)
- assert.equal(h.requests.length, 1)
- assert.deepEqual(h.events, [])
- assert.deepEqual(h.notices, ['fixture upload failure'])
- h.onPreview(h.gridItems.value[0])
- assert.deepEqual(h.previews, [])
- } finally {
- h.stop()
- }
- })
- function renderNodes(h: ReturnType<typeof harness>) {
- const template = compileTemplate({
- source: descriptor.template!.content,
- filename: 'CustomerAttachments.vue',
- id: 'attachments'
- })
- assert.deepEqual(template.errors, [])
- const exports: any = {}
- new Function(
- 'require',
- 'exports',
- ts.transpile(template.code, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext })
- )(() => ({ ...Vue, resolveComponent: (name: string) => name }), exports)
- const nodes: any[] = []
- function visit(node: any) {
- if (!node || typeof node !== 'object') return
- if (Array.isArray(node)) return node.forEach(visit)
- nodes.push(node)
- if (Array.isArray(node.children)) visit(node.children)
- }
- visit(exports.render(Vue.proxyRefs({ ...h.props, ...h }), []))
- return nodes
- }
- test('readonly attachments render no native file input while edit mode keeps the picker', () => {
- const h = harness({ readonly: true, modelValue: 'customer/existing.png' })
- try {
- assert.equal(renderNodes(h).filter((node) => node.type === 'input').length, 0)
- h.props.readonly = false
- assert.equal(renderNodes(h).filter((node) => node.type === 'input').length, 1)
- } finally {
- h.stop()
- }
- })
- test('readonly attachment picker and removal events cannot mutate the field', () => {
- const h = harness({ readonly: true, modelValue: 'customer/existing.png' })
- let clicks = 0
- h.fileInputRef.value = {
- click: () => {
- clicks++
- }
- }
- try {
- h.openPicker()
- assert.equal(clicks, 0)
- const existing = h.gridItems.value[0]
- h.onRemove(existing)
- assert.equal(h.fileList.value.length, 1)
- assert.deepEqual(h.events, [])
- h.onPreview(existing)
- h.onDownload(existing)
- assert.equal(h.previews.length, 1)
- assert.equal(h.downloads.length, 1)
- } finally {
- h.stop()
- }
- })
- test('readonly change events and direct upload calls never send a file', async () => {
- const h = harness({ readonly: true, modelValue: 'customer/existing.png' })
- h.resolve({ data: { fileUrl: 'customer/unwanted.png' } })
- const raw = new File(['fixture'], 'unwanted.png', { type: 'image/png' })
- const input = { files: [raw], value: 'selected' }
- try {
- await h.onPick({ target: input })
- await h.uploadOne(raw)
- assert.equal(h.requests.length, 0)
- assert.equal(input.value, '')
- assert.equal(h.fileList.value.length, 1)
- assert.deepEqual(h.events, [])
- } finally {
- h.stop()
- }
- })
- test('switching to readonly after opening the native picker rejects its late change event', async () => {
- const h = harness({ readonly: false })
- let clicks = 0
- h.fileInputRef.value = {
- click: () => {
- clicks++
- }
- }
- h.resolve({ data: { fileUrl: 'customer/unwanted.png' } })
- try {
- h.openPicker()
- assert.equal(clicks, 1)
- h.props.readonly = true
- await h.onPick({ target: { files: [new File(['x'], 'late.png')], value: 'selected' } })
- assert.equal(h.requests.length, 0)
- assert.deepEqual(h.events, [])
- } finally {
- h.stop()
- }
- })
|