From 90cff2d0cc096b3370db5cff501771d1996940a3 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 24 Jun 2026 10:09:36 +0800 Subject: [PATCH] test(tiptap): add UploadImageNodeView tests for rendering/update/callbacks/destroy --- .../src/__tests__/upload-image.test.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 libs/tiptap-editor/src/__tests__/upload-image.test.ts diff --git a/libs/tiptap-editor/src/__tests__/upload-image.test.ts b/libs/tiptap-editor/src/__tests__/upload-image.test.ts new file mode 100644 index 0000000..7daf474 --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/upload-image.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi } from 'vitest' +import { UploadImageNodeView, type UploadNodeViewCallbacks } from '../upload-image' + +/** + * UploadImageNodeView 单元测试(happy-dom 真实 DOM)。 + * + * 构造 fake PMNode(plain object)+ 真 happy-dom document, + * 断言三种态(uploading/error/null)的渲染 DOM、按钮 click 触发回调、destroy 调 onDestroyed。 + * + * NodeView 不依赖 editor —— 它只读 node.attrs + 操作 DOM,callbacks 由调用方注入。 + */ + +// 共用的 type sentinel:update() 用 === 比较 node.type,新旧 node 共用同一引用 +const IMAGE_TYPE = { name: 'image' } + +function makeNode(attrs: { + src?: string + 'data-upload-state'?: 'uploading' | 'error' | null + 'data-upload-id'?: string | null + 'data-error-msg'?: string | null +}) { + return { + type: IMAGE_TYPE, + attrs: { + src: attrs.src ?? 'https://example.com/img.png', + // 用 in 判断而非 ??,让显式传入的 null 生效(?? 会把 null 当缺失) + 'data-upload-state': 'data-upload-state' in attrs ? attrs['data-upload-state']! : null, + 'data-upload-id': 'data-upload-id' in attrs ? attrs['data-upload-id']! : 'upload-123', + 'data-error-msg': 'data-error-msg' in attrs ? attrs['data-error-msg']! : null, + }, + } as never // PMNode 类型注解是编译期的,运行时只需这个形状 +} + +function makeCallbacks(): UploadNodeViewCallbacks { + return { + onRetry: vi.fn<(id: string) => void>(), + onRemove: vi.fn<(id: string) => void>(), + onDestroyed: vi.fn<(id: string) => void>(), + } +} + +describe('UploadImageNodeView', () => { + describe('rendering by state', () => { + it('uploading 态:渲染 spinner + "上传中…",容器有 is-uploading', () => { + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': 'uploading', 'data-upload-id': 'u1' }), + HTMLAttributes: {}, + callbacks: makeCallbacks(), + }) + + const dom = view.dom + expect(dom.classList.contains('is-uploading')).toBe(true) + expect(dom.classList.contains('is-error')).toBe(false) + expect(dom.querySelector('.upload-spinner')).not.toBeNull() + expect(dom.querySelector('.upload-overlay-text')?.textContent).toBe('上传中…') + }) + + it('error 态:渲染错误图标 + msg + 重试/移除按钮,容器有 is-error', () => { + const view = new UploadImageNodeView({ + node: makeNode({ + 'data-upload-state': 'error', + 'data-upload-id': 'u2', + 'data-error-msg': '文件超过大小限制', + }), + HTMLAttributes: {}, + callbacks: makeCallbacks(), + }) + + const dom = view.dom + expect(dom.classList.contains('is-error')).toBe(true) + expect(dom.classList.contains('is-uploading')).toBe(false) + expect(dom.querySelector('.upload-error-icon')).not.toBeNull() + expect(dom.querySelector('.upload-error-msg')?.textContent).toBe('文件超过大小限制') + expect(dom.querySelector('.upload-btn-retry')).not.toBeNull() + expect(dom.querySelector('.upload-btn-remove')).not.toBeNull() + }) + + it('error 态缺 msg:fallback 显示"上传失败"', () => { + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': 'error', 'data-upload-id': 'u3', 'data-error-msg': null }), + HTMLAttributes: {}, + callbacks: makeCallbacks(), + }) + + expect(view.dom.querySelector('.upload-error-msg')?.textContent).toBe('上传失败') + }) + + it('null 态(普通图):无 overlay,无 is-uploading/is-error class', () => { + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': null }), + HTMLAttributes: {}, + callbacks: makeCallbacks(), + }) + + const dom = view.dom + expect(dom.querySelector('.upload-image-overlay')).toBeNull() + expect(dom.classList.contains('is-uploading')).toBe(false) + expect(dom.classList.contains('is-error')).toBe(false) + }) + }) + + describe('update()', () => { + it('切换态(uploading → error):overlay 重新渲染', () => { + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': 'uploading', 'data-upload-id': 'u4' }), + HTMLAttributes: {}, + callbacks: makeCallbacks(), + }) + expect(view.dom.querySelector('.upload-spinner')).not.toBeNull() + + const ok = view.update(makeNode({ + 'data-upload-state': 'error', + 'data-upload-id': 'u4', + 'data-error-msg': '失败', + }) as never) + + expect(ok).toBe(true) + expect(view.dom.querySelector('.upload-spinner')).toBeNull() + expect(view.dom.querySelector('.upload-error-icon')).not.toBeNull() + expect(view.dom.classList.contains('is-uploading')).toBe(false) + expect(view.dom.classList.contains('is-error')).toBe(true) + }) + + it('拒绝非同类 node:返回 false', () => { + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': null }), + HTMLAttributes: {}, + callbacks: makeCallbacks(), + }) + + const ok = view.update({ type: { name: 'paragraph' }, attrs: {} } as never) + expect(ok).toBe(false) + }) + + it('src 变化时更新 img.src', () => { + const view = new UploadImageNodeView({ + node: makeNode({ src: 'old.png', 'data-upload-state': null }), + HTMLAttributes: {}, + callbacks: makeCallbacks(), + }) + expect(view.dom.querySelector('img')?.getAttribute('src')).toBe('old.png') + + view.update(makeNode({ src: 'new.png', 'data-upload-state': null }) as never) + expect(view.dom.querySelector('img')?.getAttribute('src')).toBe('new.png') + }) + }) + + describe('button callbacks', () => { + it('点击"重试"按钮触发 onRetry(uploadId)', () => { + const callbacks = makeCallbacks() + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': 'error', 'data-upload-id': 'retry-me', 'data-error-msg': 'x' }), + HTMLAttributes: {}, + callbacks, + }) + + view.dom.querySelector('.upload-btn-retry')!.dispatchEvent(new Event('click', { bubbles: true })) + expect(callbacks.onRetry).toHaveBeenCalledWith('retry-me') + }) + + it('点击"移除"按钮触发 onRemove(uploadId)', () => { + const callbacks = makeCallbacks() + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': 'error', 'data-upload-id': 'remove-me', 'data-error-msg': 'x' }), + HTMLAttributes: {}, + callbacks, + }) + + view.dom.querySelector('.upload-btn-remove')!.dispatchEvent(new Event('click', { bubbles: true })) + expect(callbacks.onRemove).toHaveBeenCalledWith('remove-me') + }) + }) + + describe('destroy()', () => { + it('调用 onDestroyed(uploadId) —— 兜底清理 blob 的接线点', () => { + const callbacks = makeCallbacks() + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': 'error', 'data-upload-id': 'destroy-me', 'data-error-msg': 'x' }), + HTMLAttributes: {}, + callbacks, + }) + + view.destroy() + expect(callbacks.onDestroyed).toHaveBeenCalledWith('destroy-me') + }) + + it('uploadId 为 null 时 destroy 不调 onDestroyed(普通图无上传状态)', () => { + const callbacks = makeCallbacks() + const view = new UploadImageNodeView({ + node: makeNode({ 'data-upload-state': null, 'data-upload-id': null }), + HTMLAttributes: {}, + callbacks, + }) + + view.destroy() + expect(callbacks.onDestroyed).not.toHaveBeenCalled() + }) + }) +})