import { readFileSync } from 'node:fs' import { createContext, runInContext } from 'node:vm' import { ModuleKind, ScriptTarget, transpileModule } from 'typescript' import { describe, expect, it, vi } from 'vitest' import { isWorkOrderImage, workOrderAttachmentFilename, workOrderAttachmentName } from '@/utils/workOrderAttachment' function harness() { const fetchUserInfo = vi.fn().mockResolvedValue(undefined) const act = vi.fn().mockResolvedValue({}) const detail = vi.fn().mockResolvedValue({ workOrder: { actions: ['reply'], communications: [] } }) const lifecycle: Record unknown> = {} const setInterval = vi.fn().mockReturnValue(1) const clearInterval = vi.fn() const downloadFile = vi.fn() const openDocument = vi.fn() const fileManager = { mkdir: vi.fn((options: { success: () => void }) => options.success()), copyFile: vi.fn((options: { srcPath: string, destPath: string, success: () => void }) => options.success()), unlink: vi.fn((options: { complete: () => void }) => options.complete()), rmdir: vi.fn(), } const context = createContext({ setInterval, clearInterval, exports: {}, require: (name: string) => { if (name === '@dcloudio/uni-app') { return { onLoad: (callback: () => unknown) => { lifecycle.load = callback }, onShow: (callback: () => unknown) => { lifecycle.show = callback }, onHide: (callback: () => unknown) => { lifecycle.hide = callback }, onUnload: (callback: () => unknown) => { lifecycle.unload = callback }, } } if (name === '../api/workOrder') return { actOnWorkOrder: act, getWorkOrderDetail: detail } if (name === '@/store/user') return { useUserStore: () => ({ userInfo: {}, fetchUserInfo }) } if (name === '@/utils/workOrderAttachment') return { isWorkOrderImage, workOrderAttachmentName, workOrderAttachmentFilename } if (name === '@/utils') return { getEnvBaseUrl: () => 'https://api.example.com/api/v1' } if (name === '@/utils/permission') return { hasAnyFeature: (user: { revoked?: boolean }) => !user.revoked } return {} }, ref: (value: unknown) => ({ value }), computed: (getter: () => unknown) => ({ get value() { return getter() } }), nextTick: () => Promise.resolve(), definePage: vi.fn(), uni: { showToast: vi.fn(), previewImage: vi.fn(), downloadFile, openDocument }, wx: { env: { USER_DATA_PATH: '/user-data' }, getFileSystemManager: () => fileManager }, }) const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') const script = source.match(/]*>([\s\S]*?)<\/script>/)?.[1]?.replace(/\/\/ #ifdef H5[\s\S]*?\/\/ #endif/g, '').replace(/\/\/ #ifndef MP-WEIXIN[\s\S]*?\/\/ #endif/g, '') if (!script) throw new Error('未找到工单详情脚本') runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2020 } }).outputText, context) runInContext('woNo.value = \'WO-1\'; workOrder.value = { actions: [\'reply\'], communications: [] }', context) return { act, detail, fetchUserInfo, lifecycle, setInterval, clearInterval, fileManager, downloadFile, openDocument, evaluate: (expression: string) => runInContext(expression, context) as Result } } describe('工单首次加载状态', () => { it('权限和详情请求完成前持续展示骨架屏', async () => { const page = harness() page.evaluate('workOrder.value = undefined') expect(page.evaluate('loading.value')).toBe(true) let finishUser!: () => void let finishDetail!: (value: unknown) => void page.fetchUserInfo.mockImplementationOnce(() => new Promise((resolve) => { finishUser = resolve })) page.detail.mockImplementationOnce(() => new Promise((resolve) => { finishDetail = resolve })) const showing = page.lifecycle.show() expect(page.evaluate('loading.value')).toBe(true) expect(page.detail).not.toHaveBeenCalled() finishUser() await vi.waitFor(() => expect(page.detail).toHaveBeenCalledOnce()) expect(page.evaluate('loading.value')).toBe(true) finishDetail({ workOrder: { actions: ['reply'], communications: [] } }) await showing expect(page.evaluate('loading.value')).toBe(false) expect(page.evaluate('workOrder.value')).toBeDefined() }) it('详情请求真正失败后仍可重试', async () => { const page = harness() page.evaluate('workOrder.value = undefined') page.detail.mockRejectedValueOnce(new Error('network')) await page.lifecycle.show() expect(page.evaluate('loading.value')).toBe(false) expect(page.evaluate('workOrder.value')).toBeUndefined() await page.evaluate>('loadDetail()') expect(page.evaluate('workOrder.value')).toBeDefined() expect(page.evaluate('loading.value')).toBe(false) }) it('权限不足时结束加载并显示无权限提示', async () => { const page = harness() page.evaluate('workOrder.value = undefined; userStore.userInfo.revoked = true') await page.lifecycle.show() expect(page.evaluate('noPermission.value')).toBe(true) expect(page.evaluate('loading.value')).toBe(false) expect(page.detail).not.toHaveBeenCalled() }) it('通知缺少工单编号时结束加载', async () => { const page = harness() page.evaluate('workOrder.value = undefined; woNo.value = ""') await page.lifecycle.show() expect(page.evaluate('loading.value')).toBe(false) expect(page.detail).not.toHaveBeenCalled() }) it('刷新权限时保留已有工单内容', async () => { const page = harness() await page.lifecycle.show() expect(page.evaluate('loading.value')).toBe(false) expect(page.evaluate('workOrder.value')).toBeDefined() }) }) describe('工单对话回复', () => { it('发送后保持窗口打开,清空草稿并允许继续发送', async () => { const page = harness() page.evaluate('showConversation.value = true; replyContent.value = \'第一条\'') await page.evaluate>('performAction(\'reply\')') expect(page.evaluate('showConversation.value')).toBe(true) expect(page.evaluate('replyContent.value')).toBe('') expect(page.evaluate('conversationTarget.value')).toBe('conversation-bottom') page.evaluate('replyContent.value = \'第二条\'') await page.evaluate>('performAction(\'reply\')') expect(page.act).toHaveBeenCalledTimes(2) }) it('发送失败保留文字和附件草稿', async () => { const page = harness() page.act.mockRejectedValueOnce(new Error('network')) page.evaluate('replyContent.value = \'待发送\'; replyAttachments.value = [\'file.pdf\']') await page.evaluate>('performAction(\'reply\')') expect(page.evaluate('replyContent.value')).toBe('待发送') expect(page.evaluate('replyAttachments.value.length')).toBe(1) expect(page.evaluate('sending.value')).toBe(false) }) it('禁止空消息、上传期间发送和无权限回复', async () => { const page = harness() await page.evaluate>('performAction(\'reply\')') page.evaluate('replyContent.value = \'消息\'; uploading.value = true') await page.evaluate>('performAction(\'reply\')') page.evaluate('uploading.value = false; workOrder.value.actions = []') await page.evaluate>('performAction(\'reply\')') expect(page.act).not.toHaveBeenCalled() }) }) describe('工单评价与刷新', () => { it('评价使用独立草稿,不把未发送的沟通内容当成评价', async () => { const page = harness() page.evaluate('workOrder.value.actions = ["evaluate"]; replyContent.value = "未发出的沟通"; evaluationContent.value = "服务很好"') await page.evaluate>('performAction("evaluate")') expect(page.act).toHaveBeenCalledWith(expect.objectContaining({ action: 'evaluate', content: '服务很好', satisfactionLevel: 5 })) expect(page.evaluate('replyContent.value')).toBe('未发出的沟通') expect(page.evaluate('evaluationContent.value')).toBe('') }) it('评价失败保留评价草稿,无效评分不提交', async () => { const page = harness() page.act.mockRejectedValueOnce(new Error('network')) page.evaluate('workOrder.value.actions = ["evaluate"]; evaluationContent.value = "评价草稿"') await page.evaluate>('performAction("evaluate")') expect(page.evaluate('evaluationContent.value')).toBe('评价草稿') for (const rating of [0, 6, 4.5]) { page.evaluate(`workOrder.value.actions = ["evaluate"]; rating.value = ${rating}`) await page.evaluate>('performAction("evaluate")') } expect(page.act).toHaveBeenCalledTimes(1) }) it('操作成功后等待旧轮询结束,再获取最新详情', async () => { const page = harness() let finish!: (value: unknown) => void page.detail.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve })) page.detail.mockResolvedValueOnce({ workOrder: { actions: ['reply'], currentStatus: 2, communications: [{ id: 2, content: '新回复' }] } }) const polling = page.evaluate>('loadDetail(true)') page.evaluate('replyContent.value = "新回复"') const sending = page.evaluate>('performAction("reply")') await vi.waitFor(() => expect(page.act).toHaveBeenCalledTimes(1)) expect(page.detail).toHaveBeenCalledTimes(1) finish({ workOrder: { actions: ['reply'], currentStatus: 1, communications: [] } }) await Promise.all([polling, sending]) expect(page.detail).toHaveBeenCalledTimes(2) expect(page.evaluate('workOrder.value.currentStatus')).toBe(2) expect(page.evaluate('workOrder.value.communications[0].content')).toBe('新回复') }) }) describe('工单附件原名展示', () => { it('正文和消息附件都显示名称,不再使用附件序号', () => { const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') expect(source.match(/attachmentName\(url\)/g)?.length).toBe(2) expect(source).not.toContain('附件 {{ index + 1 }}') const page = harness() page.evaluate('workOrder.value.attachmentNames = { "random.pdf": "排查说明.pdf" }') expect(page.evaluate('attachmentName("random.pdf")')).toBe('排查说明.pdf') expect(page.evaluate('attachmentName("old.xlsx")')).toBe('old.xlsx') }) it('加载历史和轮询刷新后仍保留历史附件原名', async () => { const page = harness() page.evaluate('workOrder.value.communications = [{ id: 20 }]; hasMoreHistory.value = true') page.detail.mockResolvedValueOnce({ workOrder: { communications: [{ id: 10 }], attachmentNames: { 'old.pdf': '历史资料.pdf' } } }) await page.evaluate>('loadHistory()') page.detail.mockResolvedValueOnce({ workOrder: { actions: ['reply'], communications: [{ id: 21 }], attachmentNames: { 'new.xlsx': '新资料.xlsx' } } }) await page.evaluate>('loadDetail(true)') expect(page.evaluate('attachmentName("old.pdf")')).toBe('历史资料.pdf') expect(page.evaluate('attachmentName("new.xlsx")')).toBe('新资料.xlsx') expect(page.evaluate('workOrder.value.communications.length')).toBe(3) }) }) describe('工单问题描述布局', () => { it('分开展示文字说明与附件,并在内容为空时显示提示', () => { const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') const start = source.indexOf('问题描述') const end = source.indexOf('', start) const description = source.slice(start, end) expect(description).toContain('文字说明') expect(description).toContain('相关附件') expect(description).toContain('break-words') expect(description).not.toContain('break-all') expect(description).toContain('暂无问题描述') expect(description).toContain('{{ workOrder.attachmentUrls.length }} 个附件') }) }) describe('工单附件文件名预览', () => { it('微信打开的文件使用工单展示的原始名称,返回后清理本地副本', async () => { const page = harness() page.evaluate('workOrder.value.attachmentNames = { "https://files/random": "现场照片.pdf" }') page.evaluate('openAttachment("https://files/random")') page.evaluate('uni.downloadFile.mock.calls[0][0].success({ statusCode: 200, tempFilePath: "/tmp/random.pdf" })') expect(page.fileManager.copyFile).toHaveBeenCalledWith(expect.objectContaining({ srcPath: '/tmp/random.pdf', destPath: expect.stringMatching(/\/现场照片\.pdf$/), })) expect(page.openDocument).toHaveBeenCalledWith(expect.objectContaining({ filePath: expect.stringMatching(/\/现场照片\.pdf$/) })) page.lifecycle.hide() await page.lifecycle.show() expect(page.fileManager.unlink).toHaveBeenCalledWith(expect.objectContaining({ filePath: expect.stringMatching(/\/现场照片\.pdf$/) })) expect(page.fileManager.rmdir).toHaveBeenCalledWith(expect.objectContaining({ recursive: true })) }) }) describe('工单照片预览', () => { it('正文和沟通附件复用缩略图,原名为照片的随机地址也使用图片预览', () => { const page = harness() page.evaluate('workOrder.value.attachmentNames = { "/upload/random": "现场照片.JPG" }') page.evaluate('openAttachment("/upload/random")') expect(page.evaluate('uni.previewImage.mock.calls[0][0]')).toEqual({ urls: ['https://api.example.com/upload/random'], current: 'https://api.example.com/upload/random', }) const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') expect(source.match(/ { it('预览引起隐藏和重新显示时,保留窗口、草稿及阅读位置并恢复轮询', async () => { const page = harness() await page.lifecycle.show() page.evaluate('showConversation.value = true; replyContent.value = "未发送的内容"; replyAttachments.value = ["photo.jpg"]; conversationTarget.value = "history-message"') page.evaluate('openAttachment("photo.jpg")') page.lifecycle.hide() expect(page.clearInterval).toHaveBeenCalledWith(1) expect(page.evaluate('showConversation.value')).toBe(true) await page.lifecycle.show() expect(page.evaluate('showConversation.value')).toBe(true) expect(page.evaluate('replyContent.value')).toBe('未发送的内容') expect(page.evaluate('replyAttachments.value')).toEqual(['photo.jpg']) expect(page.evaluate('conversationTarget.value')).toBe('history-message') expect(page.setInterval).toHaveBeenCalledTimes(2) page.lifecycle.unload() expect(page.evaluate('visible')).toBe(false) expect(page.evaluate('refreshTimer')).toBeUndefined() }) it('从正文预览返回或手动关闭窗口后,不会自动打开沟通', async () => { const page = harness() page.evaluate('openAttachment("photo.jpg")') page.lifecycle.hide() await page.lifecycle.show() expect(page.evaluate('showConversation.value')).toBe(false) page.evaluate('showConversation.value = true; showConversation.value = false') page.lifecycle.hide() await page.lifecycle.show() expect(page.evaluate('showConversation.value')).toBe(false) }) it('重新显示时若失去查看权限,关闭沟通窗口并停止轮询', async () => { const page = harness() await page.lifecycle.show() page.evaluate('showConversation.value = true') page.lifecycle.hide() page.evaluate('userStore.userInfo.revoked = true') await page.lifecycle.show() expect(page.evaluate('showConversation.value')).toBe(false) expect(page.evaluate('noPermission.value')).toBe(true) expect(page.evaluate('refreshTimer')).toBeUndefined() }) })