test(tiptap): add UploadCoordinator tests for counts/lifecycle/emit/idempotency

This commit is contained in:
xfy 2026-06-24 10:07:43 +08:00
parent 8ff051689f
commit f7042d3ab4

View File

@ -0,0 +1,293 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { UploadCoordinator, type UploadEvent } from '../upload-coordinator'
/**
* Coordinator
*
* plain-object mock editor
* - insert / / / retry / destroy
* - blob URL createObjectURL / revokeObjectURL
* - emit kind / counts / errorMsg
* - removeUpload handleNodeDestroyed
*
* Tiptap Editor / ProseMirror schema coordinator editor 访
* keynode.type.name / node.attrs[...] plain object stub
*/
// ---- fake 节点:模拟 ProseMirror Node 的最小形状 ----
interface FakeNode {
type: { name: string }
attrs: Record<string, unknown>
nodeSize: number
}
/**
* mock editor
* nodes image descendants / findImageNodeByUploadId
* insertUploading chain.insertContentAt push
*/
function makeMockEditor(initialNodes: FakeNode[] = []) {
const nodes: FakeNode[] = [...initialNodes]
const dispatched: unknown[] = []
// setNodeMarkup / delete 调用后,把 attrs 变更同步回 nodes让后续 descendants 反映新状态)
const applyMarkup = (pos: number, attrs: Record<string, unknown>) => {
if (nodes[pos]) nodes[pos] = { ...nodes[pos], attrs: { ...nodes[pos].attrs, ...attrs } }
}
const editor = {
state: {
selection: { head: 0 },
doc: {
descendants(cb: (node: FakeNode, pos: number) => boolean | void) {
for (let i = 0; i < nodes.length; i++) {
const descend = cb(nodes[i], i)
if (descend === false) break
}
},
},
tr: {
delete(from: number, to: number) {
const marker = { op: 'delete', from, to }
nodes.splice(from, to - from)
return marker
},
setNodeMarkup(pos: number, _type: unknown, attrs: Record<string, unknown>) {
applyMarkup(pos, attrs)
return { op: 'setNodeMarkup', pos, attrs }
},
},
},
view: {
dispatch(tr: unknown) {
dispatched.push(tr)
},
},
chain() {
const chain = {
_inserted: null as { pos: number; content: { type: string; attrs: Record<string, unknown> } } | null,
focus() {
return chain
},
insertContentAt(pos: number, content: { type: string; attrs: Record<string,unknown> }) {
// 把插入的节点追加到 nodes让后续 descendants 能找到它
nodes.push({ type: { name: content.type }, nodeSize: 1, attrs: content.attrs })
chain._inserted = { pos, content }
return chain
},
run() {},
}
return chain
},
_nodes: nodes,
_dispatched: dispatched,
}
return editor
}
function makeFile(name = 'test.png'): File {
return new File(['fake-bytes'], name, { type: 'image/png' })
}
describe('UploadCoordinator', () => {
let createObjectURLSpy: ReturnType<typeof vi.spyOn>
let revokeObjectURLSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url')
revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('insert 后 uploading 计数 +1pending 存在,节点插入文档', () => {
const editor = makeMockEditor()
const upload = vi.fn().mockReturnValue(new Promise(() => {})) // 永不 resolve停在 uploading
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile())
// insertUploading 本身不 emitrunUpload 还在 awaitcounts 只能通过后续 emit 观察
expect(emit).not.toHaveBeenCalled()
expect(createObjectURLSpy).toHaveBeenCalledTimes(1)
// 节点已插入文档,处于 uploading 态
const inserted = (editor as never as { _nodes: FakeNode[] })._nodes
expect(inserted).toHaveLength(1)
expect(inserted[0].attrs['data-upload-state']).toBe('uploading')
expect(inserted[0].attrs['data-upload-id']).not.toBeNull()
expect(upload).toHaveBeenCalledTimes(1)
// pending 有一条runUpload 未完成)
expect(coord.hasPending(inserted[0].attrs['data-upload-id'] as string)).toBe(true)
})
it('上传成功counts 归 0emit successblob 被 revokepending 清除', async () => {
const editor = makeMockEditor()
const upload = vi.fn().mockResolvedValue('https://cdn.example.com/final.png')
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile())
// runUpload 是 fire-and-forgetawait 一次微任务让它完成
await Promise.resolve()
expect(emit).toHaveBeenCalledTimes(1)
const ev = emit.mock.calls[0][0] as UploadEvent
expect(ev.kind).toBe('success')
expect(ev.fileName).toBe('test.png')
expect(ev.counts).toEqual({ uploading: 0, error: 0 })
expect(revokeObjectURLSpy).toHaveBeenCalledTimes(1)
// 成功后节点 src 应更新为最终 URLupload 属性被清空
const node = (editor as never as { _nodes: FakeNode[] })._nodes[0]
expect(node.attrs.src).toBe('https://cdn.example.com/final.png')
expect(node.attrs['data-upload-id']).toBeNull()
// pending 已清
expect(coord.hasPending(ev.uploadId)).toBe(false)
})
it('上传失败counts 转 error=1emit error 带 errorMsgentry 转 error 态', async () => {
const editor = makeMockEditor()
const upload = vi.fn().mockRejectedValue(new Error('文件超过大小限制'))
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile())
await Promise.resolve()
const ev = emit.mock.calls[0][0] as UploadEvent
expect(ev.kind).toBe('error')
expect(ev.errorMsg).toBe('文件超过大小限制')
expect(ev.counts).toEqual({ uploading: 0, error: 1 })
// 节点转 error 态
const node = (editor as never as { _nodes: FakeNode[] })._nodes[0]
expect(node.attrs['data-upload-state']).toBe('error')
expect(node.attrs['data-error-msg']).toBe('文件超过大小限制')
// pending 仍在(失败保留供重试)
expect(coord.hasPending(ev.uploadId)).toBe(true)
})
it('重试error 转 uploading再次成功后归 0', async () => {
const editor = makeMockEditor()
// 第一次失败,第二次成功
const upload = vi.fn()
.mockRejectedValueOnce(new Error('网络错误'))
.mockResolvedValueOnce('https://cdn.example.com/retry.png')
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile())
await Promise.resolve() // 第一次:失败
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
expect((emit.mock.calls[0][0] as UploadEvent).counts).toEqual({ uploading: 0, error: 1 })
coord.retryUpload(uploadId)
// retry 同步调 updateNodeAttrs不 emit然后 fire-and-forget runUpload
expect(upload).toHaveBeenCalledTimes(2)
await Promise.resolve() // 第二次:成功
const successEv = emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent
expect(successEv.kind).toBe('success')
expect(successEv.counts).toEqual({ uploading: 0, error: 0 })
expect(coord.hasPending(uploadId)).toBe(false)
})
it('removeUpload点按钮按 state 减计数emit removed返回 true/false', async () => {
const editor = makeMockEditor()
const upload = vi.fn().mockRejectedValue(new Error('fail')) // 让它停在 error 态
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile())
await Promise.resolve() // 失败error=1
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
const result = coord.removeUpload(uploadId)
expect(result).toBe(true)
const removedEv = emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent
expect(removedEv.kind).toBe('removed')
expect(removedEv.counts).toEqual({ uploading: 0, error: 0 })
expect(revokeObjectURLSpy).toHaveBeenCalled()
// 不存在的 id 返回 false
expect(coord.removeUpload('nonexistent')).toBe(false)
})
it('handleNodeDestroyed退格revoke blob + 减计数 + emit removed', async () => {
const editor = makeMockEditor()
const upload = vi.fn().mockRejectedValue(new Error('fail'))
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile())
await Promise.resolve() // 失败error=1
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
coord.handleNodeDestroyed(uploadId)
const ev = emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent
expect(ev.kind).toBe('removed')
expect(ev.counts).toEqual({ uploading: 0, error: 0 })
expect(revokeObjectURLSpy).toHaveBeenCalled()
})
it('handleNodeDestroyed 对已成功的 entry 是 no-op', async () => {
const editor = makeMockEditor()
const upload = vi.fn().mockResolvedValue('https://cdn.example.com/x.png')
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile())
await Promise.resolve() // 成功pending 已清
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
const emitCountBefore = emit.mock.calls.length
coord.handleNodeDestroyed(uploadId)
// 成功的 entry 不在 pendingno-op不 emit不减计数
expect(emit.mock.calls.length).toBe(emitCountBefore)
})
it('幂等removeUpload 后再 handleNodeDestroyed 同 id 不二次减计数', async () => {
const editor = makeMockEditor()
const upload = vi.fn().mockRejectedValue(new Error('fail'))
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile())
await Promise.resolve()
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
coord.removeUpload(uploadId)
expect((emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent).counts).toEqual({ uploading: 0, error: 0 })
const callsBefore = emit.mock.calls.length
coord.handleNodeDestroyed(uploadId) // pending 已删no-op
expect(emit.mock.calls.length).toBe(callsBefore)
})
it('多图并发:计数随每张的状态独立维护', async () => {
const editor = makeMockEditor()
// 三张图:一张立即成功,一张失败,一张永远 uploading
const upload = vi.fn()
.mockResolvedValueOnce('https://cdn.example.com/1.png')
.mockRejectedValueOnce(new Error('fail'))
.mockReturnValueOnce(new Promise(() => {}))
const emit = vi.fn()
const coord = new UploadCoordinator(editor as never, upload, emit)
coord.insertUploading(makeFile('1.png'))
coord.insertUploading(makeFile('2.png'))
coord.insertUploading(makeFile('3.png'))
await Promise.resolve() // 让前两张 settle第三张永不 resolve
// 最终态uploading=1第三张, error=1第二张
const lastEv = emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent
expect(lastEv.counts).toEqual({ uploading: 1, error: 1 })
})
})