test(tiptap): add UploadImageNodeView tests for rendering/update/callbacks/destroy

This commit is contained in:
xfy 2026-06-24 10:09:36 +08:00
parent f7042d3ab4
commit 90cff2d0cc

View File

@ -0,0 +1,199 @@
import { describe, it, expect, vi } from 'vitest'
import { UploadImageNodeView, type UploadNodeViewCallbacks } from '../upload-image'
/**
* UploadImageNodeView happy-dom DOM
*
* fake PMNodeplain object+ happy-dom document
* uploading/error/null DOM click destroy onDestroyed
*
* NodeView editor node.attrs + DOMcallbacks
*/
// 共用的 type sentinelupdate() 用 === 比较 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 态缺 msgfallback 显示"上传失败"', () => {
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 → erroroverlay 重新渲染', () => {
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()
})
})
})