// usePdfApi 单元测试:验证状态机、校验、轮询、格式化逻辑(与 DOM 解耦)。 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createPdfApi, isTerminal, fmtBytes, validateFile, uploadFile, downloadUrl, } from '../src/composables/usePdfApi.js' describe('isTerminal', () => { it('done 与 failed 为终态', () => { expect(isTerminal({ status: 'done' })).toBe(true) expect(isTerminal({ status: 'failed' })).toBe(true) }) it('pending / converting 非终态', () => { expect(isTerminal({ status: 'pending' })).toBe(false) expect(isTerminal({ status: 'converting' })).toBe(false) }) it('空值安全', () => { expect(isTerminal(null)).toBe(false) expect(isTerminal(undefined)).toBe(false) }) }) describe('fmtBytes', () => { it('字节级显示 B', () => { expect(fmtBytes(500)).toBe('500 B') }) it('KB/MB 进位', () => { expect(fmtBytes(1500)).toBe('1.5 KB') expect(fmtBytes(5 * 1024 * 1024)).toBe('5.2 MB') }) it('空/非法值返回 -', () => { expect(fmtBytes(null)).toBe('-') expect(fmtBytes(NaN)).toBe('-') }) }) describe('validateFile', () => { const mk = (name, size) => ({ name, size }) it('epub 且未超限通过', () => { expect(validateFile(mk('a.epub', 1000))).toBeNull() expect(validateFile(mk('A.EPUB', 1000))).toBeNull() }) it('非 epub 拒绝', () => { expect(validateFile(mk('a.txt', 1000))).toBe('upload.requireEpub') expect(validateFile(mk('a.pdf', 1000))).toBe('upload.requireEpub') }) it('超 250MB 拒绝', () => { expect(validateFile(mk('a.epub', 250 * 1024 * 1024 + 1))).toBe('upload.oversize') }) it('自定义上限', () => { expect(validateFile(mk('a.epub', 200), 100)).toBe('upload.oversize') }) }) describe('createPdfApi', () => { it('load 拉取列表并归一化', async () => { const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ total: 2, items: [{ id: 1 }, { id: 2 }] }), }) const api = createPdfApi({ fetch: fetcher }) const r = await api.load() expect(fetcher).toHaveBeenCalledWith('/api/pdf/jobs', { credentials: 'same-origin' }) expect(r.total).toBe(2) expect(r.items).toHaveLength(2) }) it('load 处理空响应', async () => { const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }) const api = createPdfApi({ fetch: fetcher }) const r = await api.load() expect(r.total).toBe(0) expect(r.items).toEqual([]) }) it('load HTTP 错误抛异常', async () => { const fetcher = vi.fn().mockResolvedValue({ ok: false, status: 500 }) const api = createPdfApi({ fetch: fetcher }) await expect(api.load()).rejects.toThrow('HTTP 500') }) it('remove 发 DELETE', async () => { const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ deleted: true }) }) const api = createPdfApi({ fetch: fetcher }) const r = await api.remove(7) expect(fetcher).toHaveBeenCalledWith('/api/pdf/jobs/7', { method: 'DELETE', credentials: 'same-origin', }) expect(r.deleted).toBe(true) }) it('downloadUrl 生成路径', () => { expect(downloadUrl(3)).toBe('/api/pdf/jobs/3/download') }) it('pollPending 无未完成任务立即 onDone', () => { const api = createPdfApi({ fetch: vi.fn() }) const onDone = vi.fn() const onUpdate = vi.fn() const stop = api.pollPending( [{ id: 1, status: 'done' }], onUpdate, onDone ) expect(onDone).toHaveBeenCalledTimes(1) expect(onUpdate).not.toHaveBeenCalled() stop() }) it('pollPending 轮询未完成项直至终态', async () => { vi.useFakeTimers() // getJob 第 1 次返回 converting,第 2 次返回 done const states = [{ status: 'converting', progress: 50 }, { status: 'done', progress: 100 }] let call = 0 const fetcher = vi.fn().mockImplementation(async () => ({ ok: true, json: async () => ({ id: 1, ...states[Math.min(call++, states.length - 1)] }), })) const api = createPdfApi({ fetch: fetcher }) const updated = [] const onDone = vi.fn() const stop = api.pollPending( [{ id: 1, status: 'pending', progress: 0 }], (fresh) => updated.push(fresh), onDone ) // 第 1 次轮询:converting await vi.advanceTimersByTimeAsync(1500) expect(updated[0][0].status).toBe('converting') // 第 2 次轮询:done -> 触发 onDone await vi.advanceTimersByTimeAsync(1500) expect(onDone).toHaveBeenCalledTimes(1) expect(updated[1][0].status).toBe('done') stop() vi.useRealTimers() }) it('pollPending stop 提前停止', async () => { vi.useFakeTimers() const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: 1, status: 'converting', progress: 30 }), }) const api = createPdfApi({ fetch: fetcher }) const onUpdate = vi.fn() const stop = api.pollPending([{ id: 1, status: 'pending' }], onUpdate) stop() await vi.advanceTimersByTimeAsync(3000) expect(onUpdate).not.toHaveBeenCalled() vi.useRealTimers() }) }) describe('uploadFile', () => { it('成功解析响应', async () => { const fakeXhr = vi.fn().mockImplementation(() => { const x = { open: vi.fn(), send: vi.fn(), upload: {}, status: 200, responseText: JSON.stringify({ job: { id: 9 } }), } // 模拟 send 后异步触发 onload setTimeout(() => x.onload && x.onload(), 0) return x }) const res = await uploadFile({ name: 'a.epub', size: 1 }, () => {}, { xhr: fakeXhr }) expect(res.job.id).toBe(9) }) it('HTTP 错误抛含 detail', async () => { const fakeXhr = vi.fn().mockImplementation(() => { const x = { open: vi.fn(), send: vi.fn(), upload: {}, status: 413, responseText: JSON.stringify({ detail: 'too big' }), } setTimeout(() => x.onload && x.onload(), 0) return x }) await expect( uploadFile({ name: 'a.epub', size: 1 }, () => {}, { xhr: fakeXhr }) ).rejects.toThrow('too big') }) })