test(tiptap): add UploadCoordinator tests for counts/lifecycle/emit/idempotency
This commit is contained in:
parent
8ff051689f
commit
f7042d3ab4
293
libs/tiptap-editor/src/__tests__/upload-coordinator.test.ts
Normal file
293
libs/tiptap-editor/src/__tests__/upload-coordinator.test.ts
Normal 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 的访问
|
||||||
|
* 全是字符串 key(node.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 计数 +1,pending 存在,节点插入文档', () => {
|
||||||
|
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 本身不 emit(runUpload 还在 await);counts 只能通过后续 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 归 0,emit success,blob 被 revoke,pending 清除', 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-forget,await 一次微任务让它完成
|
||||||
|
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 应更新为最终 URL,upload 属性被清空
|
||||||
|
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=1,emit error 带 errorMsg,entry 转 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 不在 pending,no-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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user