ManimCat / frontend /src /hooks /useGeneration.test.tsx
Bin29's picture
Sync from main: 68df783 feat: support multimodal studio reference images
d47b053
import type { ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useGeneration } from './useGeneration'
import { cancelJob, generateAnimation, getJobStatus, modifyAnimation } from '../lib/api'
import { I18nProvider } from '../i18n'
vi.mock('../lib/api', () => ({
generateAnimation: vi.fn(),
getJobStatus: vi.fn(),
cancelJob: vi.fn(),
modifyAnimation: vi.fn(),
}))
vi.mock('../lib/settings', () => ({
loadSettings: () => ({
video: { timeout: 1200 },
api: {},
}),
}))
vi.mock('../lib/ai-providers', () => ({
getActiveProvider: () => null,
providerToCustomApiConfig: () => null,
}))
vi.mock('./usePrompts', () => ({
loadPrompts: () => undefined,
}))
const mockedGenerateAnimation = vi.mocked(generateAnimation)
const mockedGetJobStatus = vi.mocked(getJobStatus)
const mockedCancelJob = vi.mocked(cancelJob)
const mockedModifyAnimation = vi.mocked(modifyAnimation)
function wrapper({ children }: { children: ReactNode }) {
return <I18nProvider>{children}</I18nProvider>
}
describe('useGeneration', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
sessionStorage.clear()
mockedGenerateAnimation.mockResolvedValue({
success: true,
jobId: 'job-1',
message: 'ok',
status: 'processing',
submittedAt: '2026-03-22T00:00:00.000Z',
})
mockedModifyAnimation.mockResolvedValue({
success: true,
jobId: 'job-1',
message: 'ok',
status: 'processing',
submittedAt: '2026-03-22T00:00:00.000Z',
})
mockedCancelJob.mockResolvedValue()
mockedGetJobStatus.mockResolvedValue({
jobId: 'job-1',
status: 'processing',
stage: 'analyzing',
message: 'running',
submitted_at: '2026-03-22T00:00:00.000Z',
revision: 1,
attempt: 1,
})
})
afterEach(() => {
vi.useRealTimers()
})
it('does not cancel the job when polling hits a non-network error', async () => {
mockedGetJobStatus.mockRejectedValueOnce(new Error('Unexpected JSON parse failure'))
const { result } = renderHook(() => useGeneration(), { wrapper })
await act(async () => {
await result.current.generate({ concept: 'test', outputMode: 'video' })
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current.status).toBe('error')
expect(result.current.error).toBe('Unexpected JSON parse failure')
expect(mockedCancelJob).not.toHaveBeenCalled()
})
it('restores an active job from session storage and resumes polling', async () => {
sessionStorage.setItem('manimcat_active_job', JSON.stringify({
jobId: 'job-restore',
}))
mockedGetJobStatus.mockResolvedValueOnce({
jobId: 'job-restore',
status: 'processing',
stage: 'rendering',
message: 'running',
submitted_at: '2026-03-22T00:00:00.000Z',
revision: 3,
attempt: 1,
})
const { result } = renderHook(() => useGeneration(), { wrapper })
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current.jobId).toBe('job-restore')
expect(result.current.status).toBe('processing')
expect(result.current.submittedAt).toBe('2026-03-22T00:00:00.000Z')
})
it('resumes polling if cancel request fails', async () => {
mockedCancelJob.mockRejectedValueOnce(new Error('cancel failed'))
const { result } = renderHook(() => useGeneration(), { wrapper })
await act(async () => {
await result.current.generate({ concept: 'test', outputMode: 'video' })
})
await act(async () => {
result.current.cancel()
await Promise.resolve()
await Promise.resolve()
})
expect(result.current.status).toBe('processing')
expect(result.current.jobId).toBe('job-1')
expect(result.current.submittedAt).toBe('2026-03-22T00:00:00.000Z')
})
it('persists submittedAt from the backend response for resumed timing', async () => {
const { result } = renderHook(() => useGeneration(), { wrapper })
await act(async () => {
await result.current.generate({ concept: 'test', outputMode: 'video' })
})
expect(result.current.submittedAt).toBe('2026-03-22T00:00:00.000Z')
expect(sessionStorage.getItem('manimcat_active_job')).toBe('{"jobId":"job-1"}')
})
it('ignores stale poll responses with an older revision', async () => {
mockedGetJobStatus
.mockResolvedValueOnce({
jobId: 'job-1',
status: 'processing',
stage: 'rendering',
message: 'running',
submitted_at: '2026-03-22T00:00:00.000Z',
revision: 5,
attempt: 2,
})
.mockResolvedValueOnce({
jobId: 'job-1',
status: 'processing',
stage: 'generating',
message: 'stale',
submitted_at: '2026-03-22T00:00:00.000Z',
revision: 4,
attempt: 1,
})
const { result } = renderHook(() => useGeneration(), { wrapper })
await act(async () => {
await result.current.generate({ concept: 'test', outputMode: 'video' })
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current.stage).toBe('rendering')
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current.stage).toBe('rendering')
})
})