From f7042d3ab410d557db1d440c334c24b83848278c Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 24 Jun 2026 10:07:43 +0800 Subject: [PATCH] test(tiptap): add UploadCoordinator tests for counts/lifecycle/emit/idempotency --- .../src/__tests__/upload-coordinator.test.ts | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 libs/tiptap-editor/src/__tests__/upload-coordinator.test.ts diff --git a/libs/tiptap-editor/src/__tests__/upload-coordinator.test.ts b/libs/tiptap-editor/src/__tests__/upload-coordinator.test.ts new file mode 100644 index 0000000..0442be2 --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/upload-coordinator.test.ts @@ -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 + 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) => { + 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) { + 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 } } | null, + focus() { + return chain + }, + insertContentAt(pos: number, content: { type: string; attrs: Record }) { + // 把插入的节点追加到 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 + let revokeObjectURLSpy: ReturnType + + 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 }) + }) +})