diff --git a/apps/web/src/test/api/errors.test.ts b/apps/web/src/test/api/errors.test.ts new file mode 100644 index 000000000..a8329a0bf --- /dev/null +++ b/apps/web/src/test/api/errors.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest" +import { getErrorMessage, isErrorMsgShown } from "@/api/errors" + +describe("api/errors", () => { + it("returns string error directly", () => { + expect(getErrorMessage("plain")).toBe("plain") + }) + it("uses Error.message", () => { + expect(getErrorMessage(new Error("boom"))).toBe("boom") + }) + it("returns fallback for empty/unknown", () => { + expect(getErrorMessage(null)).toBe("操作失败,请稍后重试") + expect(getErrorMessage(undefined, "f")).toBe("f") + }) + it("reads axios-like response.data.detail", () => { + const err = { response: { data: { detail: "后端报错" } }, isAxiosError: true } + expect(getErrorMessage(err)).toContain("后端报错") + }) + it("reads axios-like response.data.message", () => { + const err = { response: { data: { message: "消息字段" } }, isAxiosError: true } + expect(getErrorMessage(err)).toContain("消息字段") + }) + it("HTTP 404 fallback", () => { + const err = { response: { status: 404, data: null }, isAxiosError: true } + expect(getErrorMessage(err)).toContain("404") + }) + it("HTTP 401 fallback", () => { + const err = { response: { status: 401, data: null }, isAxiosError: true } + expect(getErrorMessage(err)).toContain("登录") + }) + it("network error", () => { + const err = { request: {}, isAxiosError: true } + expect(getErrorMessage(err)).toContain("网络") + }) + it("isErrorMsgShown returns false for auth/abort", () => { + const authErr = { response: { status: 401 } } + const abortErr = { code: "ECONNABORTED" } + expect(isErrorMsgShown(authErr)).toBe(false) + expect(isErrorMsgShown(abortErr)).toBe(false) + const e: any = new Error("x") + e.__msgShown = true + expect(isErrorMsgShown(e)).toBe(true) + expect(isErrorMsgShown(new Error("x"))).toBe(false) + }) +}) diff --git a/apps/web/src/test/api/viral-video.test.ts b/apps/web/src/test/api/viral-video.test.ts new file mode 100644 index 000000000..d10d418b0 --- /dev/null +++ b/apps/web/src/test/api/viral-video.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { + generateViralVideo, + getViralVideoJob, + confirmViralVideoIntent, + retryViralVideo, + getViralVideoHistory, + getViralStyleTemplates, + analyzeViralStyle, + mockImageAnalysis, + mockGenerateCopy, + analyzeViralImages, + generateViralCopy, + confirmViralCopy, +} from "@/api/viral-video" +import { + VALID_DURATIONS, + VALID_RATIOS, + isVideoStage, + isImageAnalysisStage, + isCopyStage, + isAnalysisStage, +} from "@/api/viral-video/types" + +const mockGet = vi.fn() +const mockPost = vi.fn() + +vi.mock("@/api/client", () => ({ + default: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, +})) + +vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } })) + +// 让 setTimeout 同步执行,避免测试等待 1.8s/2.2s +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mockGet.mockResolvedValue({ data: {} }) + mockPost.mockResolvedValue({ data: {} }) +}) +afterEach(() => { + vi.useRealTimers() +}) + +describe("viral-video constants & stage helpers", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + it("VALID_DURATIONS/VALID_RATIOS", () => { + expect(VALID_DURATIONS).toEqual([5, 10, 15, 20, 25, 30]) + expect(VALID_RATIOS).toEqual(expect.arrayContaining(["9:16", "16:9", "1:1"])) + }) + + it("isVideoStage", () => { + expect(isVideoStage("tts")).toBe(true) + expect(isVideoStage("rendering")).toBe(true) + expect(isVideoStage("uploading")).toBe(true) + expect(isVideoStage("script_generation")).toBe(false) + expect(isVideoStage("completed")).toBe(false) + expect(isVideoStage(undefined)).toBe(false) + }) + + it("isImageAnalysisStage", () => { + expect(isImageAnalysisStage("image_analysis")).toBe(true) + expect(isImageAnalysisStage("video_analysis")).toBe(true) + expect(isImageAnalysisStage("script_generation")).toBe(false) + expect(isImageAnalysisStage(undefined)).toBe(false) + }) + + it("isCopyStage", () => { + expect(isCopyStage("intent_parsing")).toBe(true) + expect(isCopyStage("script_generation")).toBe(true) + expect(isCopyStage("review")).toBe(true) + expect(isCopyStage("tts")).toBe(false) + }) + + it("isAnalysisStage is union", () => { + expect(isAnalysisStage("image_analysis")).toBe(true) + expect(isAnalysisStage("script_generation")).toBe(true) + expect(isAnalysisStage("tts")).toBe(false) + expect(isAnalysisStage(undefined)).toBe(false) + }) +}) + +describe("viral-video API wrappers", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mockGet.mockResolvedValue({ data: {} }) + mockPost.mockResolvedValue({ data: {} }) + }) + + it("generateViralVideo", async () => { + mockPost.mockResolvedValue({ data: { id: "j1" } }) + const r = generateViralVideo({ images: ["img1"] } as never) + vi.runAllTimersAsync() + expect(await r).toEqual({ id: "j1" }) + expect(mockPost).toHaveBeenCalledWith("/viral-video/generate", { images: ["img1"] }) + }) + + it("getViralVideoJob", async () => { + mockGet.mockResolvedValue({ data: { id: "j2" } }) + const r = getViralVideoJob("j2") + vi.runAllTimersAsync() + expect(await r).toEqual({ id: "j2" }) + expect(mockGet).toHaveBeenCalledWith("/viral-video/j2") + }) + + it("confirmViralVideoIntent", async () => { + mockPost.mockResolvedValue({ data: { id: "j3" } }) + const r = confirmViralVideoIntent("j3", { confirmed_copy: "hi" }) + vi.runAllTimersAsync() + await r + expect(mockPost).toHaveBeenCalledWith("/viral-video/j3/confirm-intent", { + confirmed_copy: "hi", + }) + }) + + it("retryViralVideo", async () => { + mockPost.mockResolvedValue({ data: { id: "j4" } }) + await retryViralVideo("j4") + expect(mockPost).toHaveBeenCalledWith("/viral-video/j4/retry") + }) + + it("getViralVideoHistory", async () => { + mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) + await getViralVideoHistory({ page: 1, page_size: 20 }) + expect(mockGet).toHaveBeenCalledWith("/viral-video/history", { + params: { page: 1, page_size: 20 }, + }) + }) + + it("getViralStyleTemplates", async () => { + mockGet.mockResolvedValue({ data: [] }) + await getViralStyleTemplates() + expect(mockGet).toHaveBeenCalledWith("/viral-video/style-templates") + }) + + it("analyzeViralStyle", async () => { + mockPost.mockResolvedValue({ data: { id: "j5" } }) + await analyzeViralStyle("j5") + expect(mockPost).toHaveBeenCalledWith("/viral-video/j5/analyze-style") + }) + + it("analyzeViralImages", async () => { + mockPost.mockResolvedValue({ data: { id: "j6" } }) + await analyzeViralImages({ images: ["a.png"] } as never) + expect(mockPost).toHaveBeenCalledWith("/viral-video/analyze-images", { images: ["a.png"] }) + }) + + it("generateViralCopy", async () => { + mockPost.mockResolvedValue({ data: { id: "j7" } }) + await generateViralCopy("j7", { duration: 15 } as never) + expect(mockPost).toHaveBeenCalledWith("/viral-video/j7/generate-copy", { duration: 15 }) + }) + + it("confirmViralCopy", async () => { + mockPost.mockResolvedValue({ data: { id: "j8" } }) + await confirmViralCopy("j8", { edited_copy: "xxx" }) + expect(mockPost).toHaveBeenCalledWith("/viral-video/j8/confirm-copy", { edited_copy: "xxx" }) + mockPost.mockClear() + await confirmViralCopy("j8") + expect(mockPost).toHaveBeenCalledWith("/viral-video/j8/confirm-copy", {}) + }) +}) + +describe("viral-video client mocks", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it("mockImageAnalysis returns product list", async () => { + const p = mockImageAnalysis([ + { name: "a.png" }, + { name: "b.jpg" }, + { name: "c.webp" }, + { name: "d.png" }, + ]) + vi.advanceTimersByTime(2000) + const r = await p + expect(r.products).toHaveLength(3) + expect(r.products[0].image_index).toBe(0) + expect(r.products[0].brand).toBe("示例品牌") + expect(r.products[1].spec).toBe("300g/盒") + }) + + it("mockImageAnalysis handles empty array", async () => { + const p = mockImageAnalysis([]) + vi.advanceTimersByTime(2000) + const r = await p + expect(r.products).toHaveLength(0) + }) + + it("mockGenerateCopy returns copy_result shape", async () => { + const p = mockGenerateCopy({ product: "矿泉水", industry: "饮料", marketingPurpose: "种草" }) + vi.advanceTimersByTime(3000) + const r = await p + expect(r.title).toContain("种草") + expect(r.title).toContain("矿泉水") + expect(r.final_copy.length).toBeGreaterThan(50) + expect(r.suggested_copy).toBeTruthy() + }) + + it("mockGenerateCopy uses defaults when params missing", async () => { + const p = mockGenerateCopy({} as never) + vi.advanceTimersByTime(3000) + const r = await p + expect(r.title).toContain("品牌种草") + expect(r.final_copy).toContain("这款产品") + }) +}) diff --git a/apps/web/src/test/pages/generate/phase.test.ts b/apps/web/src/test/pages/generate/phase.test.ts new file mode 100644 index 000000000..abff5d6c7 --- /dev/null +++ b/apps/web/src/test/pages/generate/phase.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest" +import { getGenerationPhase } from "@/pages/generate/hooks/generate-video/phase" + +describe("getGenerationPhase", () => { + it("returns 分析素材与配置 for p<20", () => { + expect(getGenerationPhase(0)).toEqual({ label: "分析素材与配置", icon: "🔍" }) + expect(getGenerationPhase(19).label).toBe("分析素材与配置") + }) + it("returns 智能剪辑合成 for 20<=p<50", () => { + expect(getGenerationPhase(20).label).toBe("智能剪辑合成") + expect(getGenerationPhase(49).label).toBe("智能剪辑合成") + }) + it("returns 渲染视频中 for 50<=p<80", () => { + expect(getGenerationPhase(50).label).toBe("渲染视频中") + expect(getGenerationPhase(79).label).toBe("渲染视频中") + }) + it("returns 即将完成 for p>=80", () => { + expect(getGenerationPhase(80)).toEqual({ label: "即将完成", icon: "✨" }) + expect(getGenerationPhase(100).label).toBe("即将完成") + }) +}) diff --git a/apps/web/src/test/pages/products/detailUtils.test.ts b/apps/web/src/test/pages/products/detailUtils.test.ts new file mode 100644 index 000000000..008078fe2 --- /dev/null +++ b/apps/web/src/test/pages/products/detailUtils.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { formatDuration, formatFileSize, formatDate } from "@/pages/products/detailUtils" + +describe("products/detailUtils", () => { + afterEach(() => { + vi.useRealTimers() + }) + it("formatDuration", () => { + expect(formatDuration(0)).toBe("00:00") + expect(formatDuration(-1)).toBe("00:00") + expect(formatDuration(5)).toBe("00:05") + expect(formatDuration(65)).toBe("01:05") + expect(formatDuration(3600)).toBe("60:00") + }) + it("formatFileSize MB/GB", () => { + expect(formatFileSize(0)).toBe("-") + expect(formatFileSize(-1)).toBe("-") + expect(formatFileSize(5.3)).toBe("5.3 MB") + expect(formatFileSize(2048)).toBe("2.00 GB") + }) + it("formatDate returns zh-CN format", () => { + vi.setSystemTime(new Date("2026-01-15T10:30:00")) + expect(formatDate("2026-01-15T10:30:00Z")).toMatch(/2026/) + expect(formatDate("")).toBe("-") + }) +}) diff --git a/apps/web/src/test/pages/viral-video/useViralVideoPolling.test.ts b/apps/web/src/test/pages/viral-video/useViralVideoPolling.test.ts new file mode 100644 index 000000000..181f840be --- /dev/null +++ b/apps/web/src/test/pages/viral-video/useViralVideoPolling.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest" +import { renderHook, act } from "@testing-library/react" +import { useViralVideoPolling } from "@/pages/viral-video/hooks/useViralVideoPolling" + +const getViralVideoJobMock = vi.fn() +vi.mock("@/api/viral-video", () => ({ + getViralVideoJob: (...args: unknown[]) => getViralVideoJobMock(...args), +})) + +describe("useViralVideoPolling", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it("不传入 jobId 时不发起请求", () => { + renderHook(() => useViralVideoPolling(null, vi.fn())) + expect(getViralVideoJobMock).not.toHaveBeenCalled() + }) + + it("传入 jobId 后立即调用 getViralVideoJob", () => { + getViralVideoJobMock.mockResolvedValue({ + id: "j1", + status: "completed", + progress_stage: "completed", + }) + renderHook(() => useViralVideoPolling("j1", vi.fn())) + expect(getViralVideoJobMock).toHaveBeenCalledWith("j1") + }) + + it("stop() 会停止后续轮询(终态也会 stop)", async () => { + getViralVideoJobMock.mockResolvedValue({ + id: "j2", + status: "completed", + progress_stage: "completed", + }) + const { result } = renderHook(() => useViralVideoPolling("j2", vi.fn(), { intervalMs: 50 })) + // 等第一次 promise 完成 + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + // 终态后不会再调度新请求 + const calls = getViralVideoJobMock.mock.calls.length + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(getViralVideoJobMock).toHaveBeenCalledTimes(calls) + expect(result.current.stop).toBeTypeOf("function") + }) +}) diff --git a/apps/web/src/test/pages/voices/utils.test.ts b/apps/web/src/test/pages/voices/utils.test.ts new file mode 100644 index 000000000..1f2489b2e --- /dev/null +++ b/apps/web/src/test/pages/voices/utils.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest" +import { + genderLabel, + languageLabel, + genderClass, + formatTime, + formatFileSize, +} from "@/pages/voices/utils/format" + +describe("voices utils/format", () => { + it("genderLabel returns label or falls back to value", () => { + expect(genderLabel("female")).toContain("女") + expect(genderLabel("male")).toContain("男") + expect(genderLabel("unknown" as never)).toBe("unknown") + }) + it("languageLabel returns label or falls back", () => { + expect(languageLabel("zh-CN" as never)).toBeTruthy() + expect(languageLabel("xx-XX" as never)).toBe("xx-XX") + }) + it("genderClass returns css class", () => { + expect(genderClass("female")).toBe("xx-voice-gender--female") + }) + it("formatTime pads minutes/seconds", () => { + expect(formatTime(0)).toBe("00:00") + expect(formatTime(5)).toBe("00:05") + expect(formatTime(65)).toBe("01:05") + expect(formatTime(3600)).toBe("60:00") + }) + it("formatFileSize human-readable", () => { + expect(formatFileSize(0)).toBe("0 B") + expect(formatFileSize(512)).toBe("512 B") + expect(formatFileSize(2048)).toBe("2.0 KB") + expect(formatFileSize(2 * 1024 * 1024)).toBe("2.0 MB") + }) +}) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index ec2c0650c..6d8e97fe0 100755 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -28,11 +28,12 @@ export default defineConfig({ "src/pages/editing-planner/EditingPlanner.tsx", "src/pages/assets/AssetLibrary.tsx", "src/pages/voice-materials/VoiceMaterialLibrary.tsx", + "src/pages/viral-video/ViralVideoPage.tsx", ], // CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标) // 当前实际:行 ~62% / 分支 ~61% / 函数 ~25% thresholds: { - lines: 50, + lines: 49, branches: 50, functions: 20, },