From 26f7983dc16fdb83f96c70fec045fa3cc7edc633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sun, 19 Jul 2026 14:51:10 +0800 Subject: [PATCH 1/3] =?UTF-8?q?test:=20=E5=89=8D=E7=AB=AF=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=E8=A6=86=E7=9B=96=E7=8E=87Phase4=EF=BC=8C=E8=A1=8C?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=8751%=E2=86=9262%=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9Eapi/client=E6=B5=8B=E8=AF=95=EF=BC=88axios=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E5=99=A8=E6=B7=B1=E5=BA=A6=E6=B5=8B=E8=AF=95=EF=BC=8C?= =?UTF-8?q?27=E7=94=A8=E4=BE=8B=EF=BC=89=20-=20=E6=89=A9=E5=B1=95api/auth?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88=E4=BB=8E2=E4=B8=AA=E5=88=B021?= =?UTF-8?q?=E4=B8=AA=E7=94=A8=E4=BE=8B=EF=BC=89=20-=20=E6=96=B0=E5=A2=9E4?= =?UTF-8?q?=E4=B8=AA=E5=A4=A7=E9=A1=B5=E9=9D=A2smoke=20test=EF=BC=88AssetL?= =?UTF-8?q?ibrary/EditingPlanner/GeneratePage/VoiceMaterialLibrary?= =?UTF-8?q?=EF=BC=89=20-=20=E6=96=B0=E5=A2=9Erouter=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20-=20=E6=96=B0=E5=A2=9Eediting-planner=20ho?= =?UTF-8?q?oks=E5=92=8Ctypes=E6=B5=8B=E8=AF=95=20-=20=E6=80=BB=E8=AE=A1?= =?UTF-8?q?=EF=BC=9A92=E2=86=9294=E4=B8=AA=E6=B5=8B=E8=AF=95=E6=96=87?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C452=E2=86=92553=E4=B8=AA=E7=94=A8=E4=BE=8B=20?= =?UTF-8?q?-=20=E8=A1=8C=E8=A6=86=E7=9B=96=E7=8E=87=EF=BC=9A51.17%?= =?UTF-8?q?=E2=86=9261.91%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/test/api/auth.test.ts | 223 +++++++- apps/web/src/test/api/client.test.ts | 324 ++++++++++++ apps/web/src/test/pages/AssetLibrary.test.tsx | 148 ++++++ .../src/test/pages/EditingPlanner.test.tsx | 327 ++++++++++++ apps/web/src/test/pages/GeneratePage.test.tsx | 287 ++++++++++ .../test/pages/VoiceMaterialLibrary.test.tsx | 225 ++++++++ .../hooks/useEditPlanClips.test.ts | 493 ++++++++++++++++++ .../editing-planner/types/subtitle.test.ts | 73 +++ apps/web/src/test/router/index.test.tsx | 224 ++++++++ 9 files changed, 2322 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/test/api/client.test.ts create mode 100644 apps/web/src/test/pages/AssetLibrary.test.tsx create mode 100644 apps/web/src/test/pages/EditingPlanner.test.tsx create mode 100644 apps/web/src/test/pages/GeneratePage.test.tsx create mode 100644 apps/web/src/test/pages/VoiceMaterialLibrary.test.tsx create mode 100644 apps/web/src/test/pages/editing-planner/hooks/useEditPlanClips.test.ts create mode 100644 apps/web/src/test/pages/editing-planner/types/subtitle.test.ts create mode 100644 apps/web/src/test/router/index.test.tsx diff --git a/apps/web/src/test/api/auth.test.ts b/apps/web/src/test/api/auth.test.ts index 55b256775..86b0a0116 100644 --- a/apps/web/src/test/api/auth.test.ts +++ b/apps/web/src/test/api/auth.test.ts @@ -1,5 +1,36 @@ -import { describe, expect, it } from "vitest" -import { normalizeUser } from "@/api/auth" +import { describe, expect, it, vi, beforeEach } from "vitest" +import { + normalizeUser, + login, + register, + logout, + getCurrentUser, + refreshAccessToken, + requestPasswordReset, + resetPassword, + verifyEmail, +} from "@/api/auth" + +const mockPost = vi.fn() +const mockGet = vi.fn() +const mockAxiosPost = vi.fn() + +vi.mock("@/api/client", () => ({ + default: { + post: (...args: unknown[]) => mockPost(...args), + get: (...args: unknown[]) => mockGet(...args), + defaults: { baseURL: "/api/v1" }, + }, +})) + +vi.mock("axios", () => ({ + default: { + post: (...args: unknown[]) => mockAxiosPost(...args), + }, + post: (...args: unknown[]) => mockAxiosPost(...args), +})) + +vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } })) describe("normalizeUser", () => { it("normalizes canonical API current-user fields", () => { @@ -44,4 +75,192 @@ describe("normalizeUser", () => { created_at: "2026-06-22T00:00:00Z", }) }) + + it("prefers id over user_id when both present", () => { + const result = normalizeUser({ + id: "id-first", + user_id: "userid-second", + email: "test@test.com", + username: "test", + display_name: "Test", + }) + expect(result.id).toBe("id-first") + expect(result.user_id).toBe("id-first") + }) + + it("prefers is_email_verified over email_verified", () => { + const result = normalizeUser({ + email: "test@test.com", + username: "test", + display_name: "Test", + is_email_verified: true, + email_verified: false, + }) + expect(result.is_email_verified).toBe(true) + expect(result.email_verified).toBe(true) + }) + + it("defaults email verified to false when both missing", () => { + const result = normalizeUser({ + email: "test@test.com", + username: "test", + display_name: "Test", + }) + expect(result.is_email_verified).toBe(false) + expect(result.email_verified).toBe(false) + }) +}) + +describe("auth API functions", () => { + beforeEach(() => { + vi.clearAllMocks() + mockPost.mockResolvedValue({ data: { success: true } }) + mockGet.mockResolvedValue({ data: {} }) + mockAxiosPost.mockResolvedValue({ data: { access_token: "tok" } }) + }) + + describe("login", () => { + it("calls login API with correct params", async () => { + mockPost.mockResolvedValue({ + data: { access_token: "acc", refresh_token: "ref", user_id: "1" }, + }) + const result = await login({ email: "test@test.com", password: "pass" }) + expect(mockPost).toHaveBeenCalledWith("/auth/login", { + email: "test@test.com", + password: "pass", + }) + expect(result.access_token).toBe("acc") + }) + + it("rejects on error", async () => { + mockPost.mockRejectedValue(new Error("login failed")) + await expect(login({ email: "t", password: "p" })).rejects.toThrow() + }) + }) + + describe("register", () => { + it("calls register API", async () => { + mockPost.mockResolvedValue({ data: { message: "ok" } }) + const result = await register({ + email: "test@test.com", + password: "pass", + username: "testuser", + }) + expect(mockPost).toHaveBeenCalledWith("/auth/register", { + email: "test@test.com", + password: "pass", + username: "testuser", + }) + expect(result.message).toBe("ok") + }) + + it("rejects on error", async () => { + mockPost.mockRejectedValue(new Error("register failed")) + await expect(register({ email: "t", password: "p", username: "u" })).rejects.toThrow() + }) + }) + + describe("logout", () => { + it("calls logout API", async () => { + mockPost.mockResolvedValue({ data: {} }) + await logout() + expect(mockPost).toHaveBeenCalledWith("/auth/logout") + }) + + it("rejects on error", async () => { + mockPost.mockRejectedValue(new Error("logout failed")) + await expect(logout()).rejects.toThrow() + }) + }) + + describe("getCurrentUser", () => { + it("fetches and normalizes user", async () => { + mockGet.mockResolvedValue({ + data: { + user_id: "u1", + email: "user@test.com", + username: "user1", + display_name: "User One", + email_verified: true, + }, + }) + const result = await getCurrentUser() + expect(mockGet).toHaveBeenCalledWith("/auth/me") + expect(result.id).toBe("u1") + expect(result.email).toBe("user@test.com") + expect(result.is_email_verified).toBe(true) + }) + + it("rejects on error", async () => { + mockGet.mockRejectedValue(new Error("fetch failed")) + await expect(getCurrentUser()).rejects.toThrow() + }) + }) + + describe("refreshAccessToken", () => { + it("calls refresh endpoint with raw axios", async () => { + mockAxiosPost.mockResolvedValue({ + data: { access_token: "new-acc", refresh_token: "new-ref" }, + }) + const result = await refreshAccessToken("old-refresh") + expect(mockAxiosPost).toHaveBeenCalledWith("/api/v1/auth/refresh", { + refresh_token: "old-refresh", + }) + expect(result.access_token).toBe("new-acc") + }) + + it("rejects on error", async () => { + mockAxiosPost.mockRejectedValue(new Error("refresh failed")) + await expect(refreshAccessToken("tok")).rejects.toThrow() + }) + }) + + describe("requestPasswordReset", () => { + it("calls forgot-password API", async () => { + mockPost.mockResolvedValue({ data: { message: "sent" } }) + const result = await requestPasswordReset("test@test.com") + expect(mockPost).toHaveBeenCalledWith("/auth/forgot-password", { + email: "test@test.com", + }) + expect(result.message).toBe("sent") + }) + + it("rejects on error", async () => { + mockPost.mockRejectedValue(new Error("failed")) + await expect(requestPasswordReset("e")).rejects.toThrow() + }) + }) + + describe("resetPassword", () => { + it("calls reset-password API", async () => { + mockPost.mockResolvedValue({ data: { message: "reset ok" } }) + const result = await resetPassword("token123", "newpass") + expect(mockPost).toHaveBeenCalledWith("/auth/reset-password", { + token: "token123", + new_password: "newpass", + }) + expect(result.message).toBe("reset ok") + }) + + it("rejects on error", async () => { + mockPost.mockRejectedValue(new Error("failed")) + await expect(resetPassword("t", "p")).rejects.toThrow() + }) + }) + + describe("verifyEmail", () => { + it("calls verify-email API", async () => { + mockPost.mockResolvedValue({ data: { message: "verified" } }) + const result = await verifyEmail("verify-token") + expect(mockPost).toHaveBeenCalledWith("/auth/verify-email", { + token: "verify-token", + }) + expect(result.message).toBe("verified") + }) + + it("rejects on error", async () => { + mockPost.mockRejectedValue(new Error("verify failed")) + await expect(verifyEmail("t")).rejects.toThrow() + }) + }) }) diff --git a/apps/web/src/test/api/client.test.ts b/apps/web/src/test/api/client.test.ts new file mode 100644 index 000000000..b9986ee12 --- /dev/null +++ b/apps/web/src/test/api/client.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" + +vi.mock("antd", () => ({ + message: { error: vi.fn(), success: vi.fn() }, +})) + +vi.mock("@/store/authStore", () => ({ + useAuthStore: { + getState: vi.fn(() => ({ + user: { id: "1", email: "test@test.com" }, + accessToken: "old-access", + refreshToken: "old-refresh", + clearAuth: vi.fn(), + setAuth: vi.fn(), + })), + }, +})) + +vi.mock("@/api/auth", () => ({ + refreshAccessToken: vi.fn(), +})) + +import { message } from "antd" +import { useAuthStore } from "@/store/authStore" +import { refreshAccessToken } from "@/api/auth" +import apiClient from "@/api/client" + +// 从真实实例取出拦截器回调 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{ + fulfilled: (config: unknown) => unknown + rejected: (error: unknown) => unknown +}> +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const responseHandlers = (apiClient as any).interceptors.response.handlers as Array<{ + fulfilled: (response: unknown) => unknown + rejected: (error: unknown) => Promise +}> + +const requestInterceptor = requestHandlers[0]?.fulfilled! +const requestErrorInterceptor = requestHandlers[0]?.rejected! +const responseInterceptor = responseHandlers[0]?.fulfilled! +const responseErrorInterceptor = responseHandlers[0]?.rejected! + +function makeAxiosError(status?: number, data?: unknown, code?: string, hasResponse = true) { + const err = { + config: { headers: {} }, + message: "error", + } as { + config: { headers: Record; _retry?: boolean; url?: string } + response?: { status: number; data: unknown } + code?: string + message: string + } + if (hasResponse && status !== undefined) { + err.response = { status, data } + } + if (code) err.code = code + return err +} + +describe("apiClient", () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }) + }) + + describe("request interceptor", () => { + it("adds Authorization header when token exists", () => { + localStorage.setItem("access_token", "test-token") + const config = { headers: {} } + const result = requestInterceptor(config) as { headers: { Authorization?: string } } + expect(result.headers.Authorization).toBe("Bearer test-token") + }) + + it("skips Authorization header when no token", () => { + const config = { headers: {} } + const result = requestInterceptor(config) as { headers: { Authorization?: string } } + expect(result.headers.Authorization).toBeUndefined() + }) + + it("rejects on request error", async () => { + const error = new Error("request error") + await expect(requestErrorInterceptor(error) as Promise).rejects.toThrow( + "request error", + ) + }) + }) + + describe("response interceptor - success", () => { + it("passes through successful response", () => { + const response = { data: { success: true }, status: 200 } + expect(responseInterceptor(response)).toBe(response) + }) + }) + + describe("response interceptor - timeout & network", () => { + it("shows timeout message for ECONNABORTED", async () => { + const err = makeAxiosError(undefined, undefined, "ECONNABORTED") + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试") + }) + + it("shows timeout message for timeout string", async () => { + const err = { ...makeAxiosError(), message: "timeout of 10000ms exceeded" } + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试") + }) + + it("shows network error when no response", async () => { + const err = makeAxiosError(undefined, undefined, undefined, false) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("网络连接异常,请检查网络设置") + }) + }) + + describe("response interceptor - server error messages", () => { + it("shows detail field", async () => { + const err = makeAxiosError(400, { detail: "参数错误" }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("参数错误") + }) + + it("shows message field", async () => { + const err = makeAxiosError(400, { message: "操作失败" }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("操作失败") + }) + + it("shows msg field", async () => { + const err = makeAxiosError(400, { msg: "出错了" }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("出错了") + }) + + it("handles nested message object", async () => { + const err = makeAxiosError(400, { message: { message: "深层错误" } }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("深层错误") + }) + + it("handles nested msg object", async () => { + const err = makeAxiosError(400, { msg: { msg: "嵌套错误" } }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("嵌套错误") + }) + + it("stringifies object with no string fields", async () => { + const err = makeAxiosError(400, { detail: { code: 123, foo: "bar" } }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith('{"code":123,"foo":"bar"}') + }) + + it("marks __msgShown when message displayed", async () => { + const err = makeAxiosError(400, { detail: "test" }) as { + config: { headers: Record } + response: { status: number; data: { detail: string } } + message: string + __msgShown?: boolean + } + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(err.__msgShown).toBe(true) + }) + }) + + describe("response interceptor - HTTP status codes", () => { + it("shows file too large for 413", async () => { + const err = makeAxiosError(413, {}) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("文件过大,请缩小后重试") + }) + + it("shows unsupported format for 415", async () => { + const err = makeAxiosError(415, {}) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("不支持的文件格式") + }) + + it("shows service unavailable for 503", async () => { + const err = makeAxiosError(503, {}) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("服务暂不可用,请稍后再试") + }) + + it("shows server busy for 500", async () => { + const err = makeAxiosError(500, {}) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试") + }) + + it("shows server busy for 502", async () => { + const err = makeAxiosError(502, {}) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试") + }) + + it("no message for 4xx without server msg", async () => { + const err = makeAxiosError(403, {}) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).not.toHaveBeenCalled() + }) + + it("no __msgShown for unhandled 4xx", async () => { + const err = makeAxiosError(403, {}) as { + config: { headers: Record } + response: { status: number; data: Record } + message: string + __msgShown?: boolean + } + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(err.__msgShown).toBeUndefined() + }) + }) + + describe("safeExtractString edge cases", () => { + it("returns empty string for numeric message", async () => { + const err = makeAxiosError(400, { message: 123 }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).not.toHaveBeenCalled() + }) + + it("returns empty string for null data", async () => { + const err = makeAxiosError(400, null) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).not.toHaveBeenCalled() + }) + + it("handles detail with nested detail object", async () => { + const err = makeAxiosError(400, { detail: { detail: "nested detail" } }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(message.error).toHaveBeenCalledWith("nested detail") + }) + }) +}) + +describe("apiClient - 401 token refresh", () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + localStorage.setItem("access_token", "old-access") + localStorage.setItem("refresh_token", "old-refresh") + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }) + }) + + it("logs out when no refresh token on 401", async () => { + const mockClearAuth = vi.fn() + vi.mocked(useAuthStore.getState).mockReturnValue({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + clearAuth: mockClearAuth, + setAuth: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + + const err = makeAxiosError(401, { detail: "Unauthorized" }) + await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() + expect(mockClearAuth).toHaveBeenCalled() + expect(window.location.href).toBe("/") + }) + + it("refreshes token on 401 and calls setAuth", async () => { + const mockSetAuth = vi.fn() + vi.mocked(useAuthStore.getState).mockReturnValue({ + user: { id: "1", email: "test@test.com" }, + accessToken: "old-access", + refreshToken: "old-refresh", + isAuthenticated: true, + clearAuth: vi.fn(), + setAuth: mockSetAuth, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + vi.mocked(refreshAccessToken).mockResolvedValue({ + access_token: "new-access", + refresh_token: "new-refresh", + } as never) + + // 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject + // 但我们只关心刷新逻辑是否正确执行 + const err = makeAxiosError(401, { detail: "Unauthorized" }) + + try { + await responseErrorInterceptor(err) + } catch { + // 重试会因为没有真实网络而失败,忽略 + } + + expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh") + expect(mockSetAuth).toHaveBeenCalled() + }) + + it("handles refresh failure by logging out", async () => { + const mockClearAuth = vi.fn() + vi.mocked(useAuthStore.getState).mockReturnValue({ + user: { id: "1", email: "test@test.com" }, + accessToken: "old-access", + refreshToken: "old-refresh", + isAuthenticated: true, + clearAuth: mockClearAuth, + setAuth: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never) + + const err = makeAxiosError(401, { detail: "Unauthorized" }) + + try { + await responseErrorInterceptor(err) + } catch { + // expected + } + + expect(mockClearAuth).toHaveBeenCalled() + expect(window.location.href).toBe("/") + }) +}) diff --git a/apps/web/src/test/pages/AssetLibrary.test.tsx b/apps/web/src/test/pages/AssetLibrary.test.tsx new file mode 100644 index 000000000..4065b12fe --- /dev/null +++ b/apps/web/src/test/pages/AssetLibrary.test.tsx @@ -0,0 +1,148 @@ +import React from "react" +import { describe, expect, it, vi } from "vitest" +import { render } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" + +vi.mock("@/components/layout/PageHead", () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(() => ({ + data: { items: [], total: 0 }, + isLoading: false, + isError: false, + refetch: vi.fn(), + })), + useMutation: vi.fn(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isLoading: false, + })), + useQueryClient: vi.fn(() => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + getQueryData: vi.fn(), + })), + useInfiniteQuery: vi.fn(() => ({ + data: { pages: [] }, + isLoading: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + })), +})) + +vi.mock("@/components/ui", () => ({ + Button: ({ children, onClick }: any) => , + Input: ({ placeholder }: any) => , + Select: () => , + InputNumber: () => , + Select: () => , + Radio: ({ children }: any) => {children}, + RadioGroup: ({ children }: any) =>
{children}
, + Drawer: ({ open, children }: any) => (open ?
{children}
: null), + Popover: ({ children }: any) => {children}, + Divider: () =>
, + Dropdown: ({ children }: any) => {children}, + Menu: () =>
, + Checkbox: ({ children }: any) => {children}, + List: () =>
, + Avatar: ({ children }: any) => {children}, + Button: ({ children, onClick }: any) => , + Result: ({ status, title }: any) =>
{title}
, + Spin: () =>
Loading
, +})) + +vi.mock("@ant-design/icons", () => ({ + PlusOutlined: () => +, + SearchOutlined: () => S, + InboxOutlined: () => I, + VideoCameraOutlined: () => V, + PictureOutlined: () => P, + PlayCircleOutlined: () => , + CheckOutlined: () => , + DeleteOutlined: () => ×, + ExperimentOutlined: () => E, + LoadingOutlined: () => L, + ExclamationCircleOutlined: () => !, + TagsOutlined: () => T, + EditOutlined: () => E, + DownloadOutlined: () => D, + MoreOutlined: () => M, + FolderOutlined: () => F, + FolderAddOutlined: () => FA, + UploadOutlined: () => U, + AudioOutlined: () => A, +})) + +vi.mock("@/api/assets", () => ({ + getAssetLibraries: vi.fn().mockResolvedValue({ items: [] }), + createAssetLibrary: vi.fn().mockResolvedValue({}), + deleteAssetLibrary: vi.fn().mockResolvedValue({}), + getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }), + deleteAsset: vi.fn().mockResolvedValue({}), + uploadAssetDirect: vi.fn().mockResolvedValue({}), + getAssetDiagnosis: vi.fn().mockResolvedValue({}), + batchDeleteAssets: vi.fn().mockResolvedValue({}), + batchTagAssets: vi.fn().mockResolvedValue({}), + batchClassifyAssets: vi.fn().mockResolvedValue({}), + batchMarkAssets: vi.fn().mockResolvedValue({}), + AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio" }, +})) + +vi.mock("@/api/tags", () => ({ + getTags: vi.fn().mockResolvedValue({ items: [] }), + createTag: vi.fn().mockResolvedValue({}), + tagAsset: vi.fn().mockResolvedValue({}), + untagAsset: vi.fn().mockResolvedValue({}), +})) + +import AssetLibrary from "@/pages/assets/AssetLibrary" + +describe("AssetLibrary", () => { + it("renders without crashing", () => { + const { container } = render( + + + , + ) + expect(container).toBeTruthy() + }) + + it("shows empty state when no assets", () => { + const { getByText } = render( + + + , + ) + // 空状态文案应该出现 + expect(getByText(/暂无素材/)).toBeTruthy() + }) +}) diff --git a/apps/web/src/test/pages/EditingPlanner.test.tsx b/apps/web/src/test/pages/EditingPlanner.test.tsx new file mode 100644 index 000000000..adc680626 --- /dev/null +++ b/apps/web/src/test/pages/EditingPlanner.test.tsx @@ -0,0 +1,327 @@ +import React from "react" +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { render, act } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" + +// === React Query mock === +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(() => ({ + data: undefined, + isLoading: false, + isError: false, + refetch: vi.fn(), + })), + useMutation: vi.fn(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isLoading: false, + })), + useQueryClient: vi.fn(() => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + getQueryData: vi.fn(), + })), +})) + +// === Ant Design Icons mock === +vi.mock("@ant-design/icons", () => ({ + VideoCameraOutlined: () => React.createElement("span", null, "V"), + PictureOutlined: () => React.createElement("span", null, "P"), + SoundOutlined: () => React.createElement("span", null, "S"), + PlusOutlined: () => React.createElement("span", null, "+"), + DeleteOutlined: () => React.createElement("span", null, "D"), + EditOutlined: () => React.createElement("span", null, "E"), + CopyOutlined: () => React.createElement("span", null, "C"), + DownloadOutlined: () => React.createElement("span", null, "D"), + PlayCircleOutlined: () => React.createElement("span", null, ">"), + PauseCircleOutlined: () => React.createElement("span", null, "||"), + LeftOutlined: () => React.createElement("span", null, "<"), + RightOutlined: () => React.createElement("span", null, ">"), + UpOutlined: () => React.createElement("span", null, "^"), + DownOutlined: () => React.createElement("span", null, "v"), + SaveOutlined: () => React.createElement("span", null, "S"), + UndoOutlined: () => React.createElement("span", null, "U"), + RedoOutlined: () => React.createElement("span", null, "R"), + CloseOutlined: () => React.createElement("span", null, "X"), + CheckOutlined: () => React.createElement("span", null, "v"), + SettingOutlined: () => React.createElement("span", null, "S"), + AppstoreOutlined: () => React.createElement("span", null, "#"), + UnorderedListOutlined: () => React.createElement("span", null, "="), + HistoryOutlined: () => React.createElement("span", null, "H"), + UploadOutlined: () => React.createElement("span", null, "U"), + SearchOutlined: () => React.createElement("span", null, "S"), + FilterOutlined: () => React.createElement("span", null, "F"), + FontColorsOutlined: () => React.createElement("span", null, "A"), + BgColorsOutlined: () => React.createElement("span", null, "B"), + AudioOutlined: () => React.createElement("span", null, "A"), + MusicOutlined: () => React.createElement("span", null, "M"), + ScissorOutlined: () => React.createElement("span", null, "X"), + ThunderboltOutlined: () => React.createElement("span", null, "T"), + ExperimentOutlined: () => React.createElement("span", null, "E"), + BulbOutlined: () => React.createElement("span", null, "B"), + FundOutlined: () => React.createElement("span", null, "F"), + LayoutOutlined: () => React.createElement("span", null, "L"), + ColumnHeightOutlined: () => React.createElement("span", null, "C"), + SwapOutlined: () => React.createElement("span", null, "S"), +})) + +// === Ant Design mock === +vi.mock("antd", () => ({ + message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, + Modal: ({ open, children, title }: any) => + open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null, + Progress: () => React.createElement("div"), + Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children), + Tabs: ({ items }: any) => + React.createElement( + "div", + null, + items?.map?.(() => React.createElement("div")), + ), + TabPane: () => React.createElement("div"), + Drawer: ({ open, children, title }: any) => + open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null, + Select: ({ children }: any) => React.createElement("select", null, children), + Option: ({ children }: any) => React.createElement("option", null, children), + Input: ({ placeholder }: any) => React.createElement("input", { placeholder }), + InputNumber: () => React.createElement("input", { type: "number" }), + Switch: () => React.createElement("input", { type: "checkbox" }), + Slider: () => React.createElement("div"), + ColorPicker: () => React.createElement("div"), + Upload: ({ children }: any) => React.createElement("div", null, children), + Space: ({ children }: any) => React.createElement("div", null, children), + Row: ({ children }: any) => React.createElement("div", null, children), + Col: ({ children }: any) => React.createElement("div", null, children), + Card: ({ children }: any) => React.createElement("div", null, children), + Tag: ({ children }: any) => React.createElement("span", null, children), + Tooltip: ({ children }: any) => React.createElement("span", null, children), + Popover: ({ children }: any) => React.createElement("span", null, children), + Dropdown: ({ children }: any) => React.createElement("span", null, children), + Menu: () => React.createElement("div"), + Divider: () => React.createElement("hr"), + Empty: () => React.createElement("div", null, "Empty"), + Spin: () => React.createElement("div", null, "Loading"), + Badge: ({ children }: any) => React.createElement("span", null, children), + Avatar: ({ children }: any) => React.createElement("span", null, children), + Checkbox: ({ children }: any) => React.createElement("span", null, children), + Radio: ({ children }: any) => React.createElement("span", null, children), + RadioGroup: ({ children }: any) => React.createElement("div", null, children), + Segmented: () => React.createElement("div"), + Collapse: ({ children }: any) => React.createElement("div", null, children), + CollapsePanel: ({ children }: any) => React.createElement("div", null, children), + Form: ({ children }: any) => React.createElement("form", null, children), + FormItem: ({ children }: any) => React.createElement("div", null, children), + List: () => React.createElement("div"), + Table: () => React.createElement("div"), + Pagination: () => React.createElement("div"), + Popconfirm: ({ children }: any) => React.createElement("span", null, children), + Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title), + ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children), +})) + +// === UI Components mock === +vi.mock("@/components/ui", () => ({ + Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children), + Input: ({ placeholder }: any) => React.createElement("input", { placeholder }), + Modal: ({ open, children }: any) => + open ? React.createElement("div", { role: "dialog" }, children) : null, + Drawer: ({ open, children }: any) => + open ? React.createElement("div", { role: "dialog" }, children) : null, + Empty: () => React.createElement("div", null, "Empty"), + Card: ({ children }: any) => React.createElement("div", null, children), + Tag: ({ children }: any) => React.createElement("span", null, children), + Tooltip: ({ children }: any) => React.createElement("span", null, children), + Select: ({ children }: any) => React.createElement("select", null, children), + Progress: () => React.createElement("div"), + Upload: ({ children }: any) => React.createElement("div", null, children), +})) + +// === API mocks === +vi.mock("@/api/editingPlanner", () => ({ + getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }), + getEditingTemplate: vi.fn().mockResolvedValue({}), + createEditingTemplate: vi.fn().mockResolvedValue({}), + updateEditingTemplate: vi.fn().mockResolvedValue({}), + getTemplateCategories: vi.fn().mockResolvedValue({ items: [] }), + MODE_LABELS: { pip: "画中画", intro_outro: "片头片尾", watermark: "水印" }, +})) + +vi.mock("@/api/editPlans", () => ({ + getMediaAssets: vi.fn().mockResolvedValue({ items: [] }), + getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }), + generateCover: vi.fn().mockResolvedValue({}), + getEditPlan: vi.fn().mockResolvedValue({}), + createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }), + updateEditPlan: vi.fn().mockResolvedValue({}), + generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }), + getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }), + getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }), + cancelGeneration: vi.fn().mockResolvedValue({}), + getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }), + createEditPlanClip: vi.fn().mockResolvedValue({}), + batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}), +})) + +vi.mock("@/api/assets", () => ({ + ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "default-lib" }), + getAssetsByKind: vi.fn().mockResolvedValue({ items: [] }), +})) + +vi.mock("@/api/projects", () => ({ + getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "default-project" }), +})) + +vi.mock("@/api/bgm", () => ({ + DEFAULT_BGM_MIX_CONFIG: { volume: 1, fade_in: 0, fade_out: 0 }, +})) + +vi.mock("@/pages/editing-planner/components/MediaPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "MediaPanel" }), +})) +vi.mock("@/pages/editing-planner/components/PreviewPlayer", () => ({ + default: () => React.createElement("div", { "data-testid": "PreviewPlayer" }), +})) +vi.mock("@/pages/editing-planner/components/TimelinePanel", () => ({ + default: () => React.createElement("div", { "data-testid": "TimelinePanel" }), +})) +vi.mock("@/pages/editing-planner/components/ClipPropertiesPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "ClipPropertiesPanel" }), +})) +vi.mock("@/pages/editing-planner/components/EditorClipList", () => ({ + default: () => React.createElement("div", { "data-testid": "EditorClipList" }), +})) +vi.mock("@/pages/editing-planner/components/BgmSelector", () => ({ + default: () => React.createElement("div", { "data-testid": "BgmSelector" }), +})) +vi.mock("@/pages/editing-planner/components/SubtitleStylePanel", () => ({ + default: () => React.createElement("div", { "data-testid": "SubtitleStylePanel" }), +})) +vi.mock("@/pages/editing-planner/components/TransitionSelector", () => ({ + default: () => React.createElement("div", { "data-testid": "TransitionSelector" }), +})) +vi.mock("@/pages/editing-planner/components/SpeedPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "SpeedPanel" }), +})) +vi.mock("@/pages/editing-planner/components/TtsPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "TtsPanel" }), +})) +vi.mock("@/pages/editing-planner/components/WatermarkPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "WatermarkPanel" }), +})) +vi.mock("@/pages/editing-planner/components/IntroOutroPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "IntroOutroPanel" }), +})) +vi.mock("@/pages/editing-planner/components/PipConfigPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "PipConfigPanel" }), +})) +vi.mock("@/pages/editing-planner/components/FilterPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "FilterPanel" }), +})) +vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "GreenScreenPanel" }), +})) +vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({ + default: () => React.createElement("div", { "data-testid": "StickerPanel" }), +})) +vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({ + default: () => React.createElement("div", { "data-testid": "CoverSelector" }), +})) +vi.mock("@/pages/editing-planner/components/SaveModal", () => ({ + default: () => React.createElement("div", { "data-testid": "SaveModal" }), +})) +vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({ + default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }), +})) +vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({ + default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }), +})) + +// === useUndoRedo hook mock === +vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({ + useUndoRedo: vi.fn((initial: any) => ({ + state: initial, + setState: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + canUndo: false, + canRedo: false, + reset: vi.fn(), + })), +})) + +// === Types mock === +vi.mock("@/pages/editing-planner/types", () => ({ + DEFAULT_TRANSITION: { type: "fade", duration: 0.5 }, + DEFAULT_SPEED: { rate: 1 }, + DEFAULT_TTS_CONFIG: { enabled: false }, + DEFAULT_WATERMARK: { enabled: false }, + DEFAULT_INTRO_OUTRO: { enabled: false }, + DEFAULT_PIP_CONFIG: { enabled: false }, + DEFAULT_FILTER_CONFIG: { enabled: false }, + DEFAULT_CHROMA_KEY_CONFIG: { enabled: false }, + DEFAULT_STICKER_CONFIG: { enabled: false }, + DEFAULT_COVER_CONFIG: { enabled: false }, +})) + +vi.mock("@/pages/editing-planner/types/subtitle", () => ({ + DEFAULT_SUBTITLE_STYLE: { + font_size: 24, + font_color: "#ffffff", + background_color: "#000000", + }, +})) + +// === PageHead mock === +vi.mock("@/components/layout/PageHead", () => ({ + default: ({ title }: { title: string }) => + React.createElement("div", { "data-testid": "page-head" }, title), +})) + +import EditingPlanner from "@/pages/editing-planner/EditingPlanner" + +describe("EditingPlanner", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("renders without crashing", () => { + const { container } = render( + React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)), + ) + expect(container).toBeTruthy() + }) + + it("renders with templateId query param", () => { + const { container } = render( + React.createElement( + MemoryRouter, + { initialEntries: ["?templateId=tpl-123"] }, + React.createElement(EditingPlanner), + ), + ) + expect(container).toBeTruthy() + }) + + it("renders with planId query param", () => { + const { container } = render( + React.createElement( + MemoryRouter, + { initialEntries: ["?planId=plan-456"] }, + React.createElement(EditingPlanner), + ), + ) + expect(container).toBeTruthy() + }) + + it("advances timers without errors", () => { + render(React.createElement(MemoryRouter, null, React.createElement(EditingPlanner))) + act(() => { + vi.advanceTimersByTime(10000) + }) + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/GeneratePage.test.tsx b/apps/web/src/test/pages/GeneratePage.test.tsx new file mode 100644 index 000000000..4cabcdbfe --- /dev/null +++ b/apps/web/src/test/pages/GeneratePage.test.tsx @@ -0,0 +1,287 @@ +import React from "react" +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { render, act } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" + +// Hoisted mock icons factory +const hoistedIcons = vi.hoisted(() => { + const iconNames = [ + "AudioOutlined", + "ThunderboltOutlined", + "CheckCircleFilled", + "CheckCircleOutlined", + "CloseCircleOutlined", + "LoadingOutlined", + "PlayCircleOutlined", + "PauseCircleOutlined", + "DownloadOutlined", + "ShareAltOutlined", + "SaveOutlined", + "PlusOutlined", + "MinusOutlined", + "CloseOutlined", + "SearchOutlined", + "EditOutlined", + "DeleteOutlined", + "UploadOutlined", + "FolderOutlined", + "FolderAddOutlined", + "MoreOutlined", + "ExperimentOutlined", + "ExclamationCircleOutlined", + "InboxOutlined", + "VideoCameraOutlined", + "PictureOutlined", + "SoundOutlined", + "UserOutlined", + "ManOutlined", + "WomanOutlined", + "TagsOutlined", + "MutedOutlined", + "RobotOutlined", + "UnorderedListOutlined", + "AppstoreOutlined", + "UndoOutlined", + "RedoOutlined", + "SettingOutlined", + "HistoryOutlined", + "FilterOutlined", + "FontColorsOutlined", + "BgColorsOutlined", + "MusicOutlined", + "ScissorOutlined", + "BulbOutlined", + "FundOutlined", + "LayoutOutlined", + "ColumnHeightOutlined", + "SwapOutlined", + "LeftOutlined", + "RightOutlined", + "UpOutlined", + "DownOutlined", + "CopyOutlined", + ] + const icons: Record = {} + iconNames.forEach((name) => { + icons[name] = () => React.createElement("span", null, name.charAt(0)) + }) + return icons +}) + +// === React Query mock === +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(() => ({ + data: undefined, + isLoading: false, + isError: false, + refetch: vi.fn(), + })), + useMutation: vi.fn(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isLoading: false, + })), + useQueryClient: vi.fn(() => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + getQueryData: vi.fn(), + })), + useInfiniteQuery: vi.fn(() => ({ + data: { pages: [] }, + isLoading: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + })), +})) + +// === Ant Design Icons mock === +vi.mock("@ant-design/icons", () => hoistedIcons) + +// === Ant Design mock === +vi.mock("antd", () => { + const Typography = { + Text: ({ children }: any) => React.createElement("span", null, children), + Title: ({ children }: any) => React.createElement("h1", null, children), + Paragraph: ({ children }: any) => React.createElement("p", null, children), + } + return { + Typography, + message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, + Modal: ({ open, children, title }: any) => + open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null, + Progress: () => React.createElement("div"), + Popover: ({ children }: any) => React.createElement("span", null, children), + Popconfirm: ({ children }: any) => React.createElement("span", null, children), + Tooltip: ({ children }: any) => React.createElement("span", null, children), + Tabs: () => React.createElement("div"), + Drawer: ({ open, children }: any) => + open ? React.createElement("div", { role: "dialog" }, children) : null, + Upload: ({ children }: any) => React.createElement("div", null, children), + Slider: () => React.createElement("div"), + Switch: () => React.createElement("input", { type: "checkbox" }), + Segmented: () => React.createElement("div"), + Spin: () => React.createElement("div", null, "Loading"), + Empty: () => React.createElement("div", null, "Empty"), + Divider: () => React.createElement("hr"), + Space: ({ children }: any) => React.createElement("div", null, children), + Dropdown: ({ children }: any) => React.createElement("span", null, children), + Menu: () => React.createElement("div"), + Badge: ({ children }: any) => React.createElement("span", null, children), + Radio: ({ children }: any) => React.createElement("span", null, children), + RadioGroup: ({ children }: any) => React.createElement("div", null, children), + Checkbox: ({ children }: any) => React.createElement("span", null, children), + InputNumber: () => React.createElement("input", { type: "number" }), + Form: ({ children }: any) => React.createElement("form", null, children), + FormItem: ({ children }: any) => React.createElement("div", null, children), + Result: ({ status, title }: any) => + React.createElement("div", { "data-status": status }, title), + List: () => React.createElement("div"), + Table: () => React.createElement("div"), + Pagination: () => React.createElement("div"), + Card: ({ children }: any) => React.createElement("div", null, children), + Avatar: ({ children }: any) => React.createElement("span", null, children), + Collapse: ({ children }: any) => React.createElement("div", null, children), + CollapsePanel: ({ children }: any) => React.createElement("div", null, children), + Tag: ({ children }: any) => React.createElement("span", null, children), + Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children), + Input: ({ placeholder }: any) => React.createElement("input", { placeholder }), + Select: ({ children }: any) => React.createElement("select", null, children), + Option: ({ children }: any) => React.createElement("option", null, children), + ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children), + TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }), + Steps: () => React.createElement("div"), + Step: () => React.createElement("div"), + } +}) + +// === UI Components mock === +vi.mock("@/components/ui", () => ({ + Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children), + Input: ({ placeholder }: any) => React.createElement("input", { placeholder }), + Modal: ({ open, children }: any) => + open ? React.createElement("div", { role: "dialog" }, children) : null, + Drawer: ({ open, children }: any) => + open ? React.createElement("div", { role: "dialog" }, children) : null, + Empty: () => React.createElement("div", null, "Empty"), + Card: ({ children }: any) => React.createElement("div", null, children), + Tag: ({ children }: any) => React.createElement("span", null, children), + Tooltip: ({ children }: any) => React.createElement("span", null, children), + Select: ({ children }: any) => React.createElement("select", null, children), + Progress: () => React.createElement("div"), + Upload: ({ children }: any) => React.createElement("div", null, children), + Slider: () => React.createElement("div"), + Switch: () => React.createElement("input", { type: "checkbox" }), +})) + +// === API mocks === +vi.mock("@/api/assets", () => ({ + getAssetLibraries: vi.fn().mockResolvedValue([]), + createAssetLibrary: vi.fn().mockResolvedValue({}), + getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }), + getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }), + createAsset: vi.fn().mockResolvedValue({}), + updateAsset: vi.fn().mockResolvedValue({}), + deleteAsset: vi.fn().mockResolvedValue({}), + uploadAssetDirect: vi.fn().mockResolvedValue({}), + batchDeleteAssets: vi.fn().mockResolvedValue({}), + AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" }, +})) + +vi.mock("@/api/tags", () => ({ + getTags: vi.fn().mockResolvedValue({ items: [] }), + createTag: vi.fn().mockResolvedValue({}), + tagAsset: vi.fn().mockResolvedValue({}), + untagAsset: vi.fn().mockResolvedValue({}), +})) + +vi.mock("@/api/tts", () => ({ + synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }), + getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }), + saveTtsToLibrary: vi.fn().mockResolvedValue({}), +})) + +vi.mock("@/api/voices", () => ({ + fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }), +})) + +vi.mock("@/api/editingPlanner", () => ({ + getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }), + MODE_LABELS: { pip: "画中画" }, +})) + +vi.mock("@/api/titles", () => ({ + getTitles: vi.fn().mockResolvedValue({ items: [] }), +})) + +vi.mock("@/api/editPlans", () => ({ + createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }), + generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }), + updateEditPlan: vi.fn().mockResolvedValue({}), + getEditPlan: vi.fn().mockResolvedValue({}), + getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }), +})) + +vi.mock("@/api/voiceClone", () => ({ + formatDuration: vi.fn((s: number) => `${s}s`), +})) + +vi.mock("@/api/client", () => ({ + default: { + interceptors: { request: { handlers: [] }, response: { handlers: [] } }, + get: vi.fn().mockResolvedValue({ data: {} }), + post: vi.fn().mockResolvedValue({ data: {} }), + put: vi.fn().mockResolvedValue({ data: {} }), + delete: vi.fn().mockResolvedValue({ data: {} }), + }, +})) + +// === Hooks mock === +vi.mock("@/hooks/useCloneProgress", () => ({ + useCloneProgress: vi.fn(() => ({ + progress: 0, + status: "idle", + start: vi.fn(), + reset: vi.fn(), + })), +})) + +// === Components mock === +vi.mock("@/components/voice/CloneModal", () => ({ + default: () => React.createElement("div", { "data-testid": "CloneModal" }), +})) + +// === PageHead mock === +vi.mock("@/components/layout/PageHead", () => ({ + default: ({ title }: { title: string }) => + React.createElement("div", { "data-testid": "page-head" }, title), +})) + +// CSS mock +vi.mock("@/pages/generate/generate.css", () => ({})) + +import GeneratePage from "@/pages/generate/GeneratePage" + +describe("GeneratePage", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("renders without crashing", () => { + const { container } = render( + React.createElement(MemoryRouter, null, React.createElement(GeneratePage)), + ) + expect(container).toBeTruthy() + }) + + it("advances timers without errors", () => { + render(React.createElement(MemoryRouter, null, React.createElement(GeneratePage))) + act(() => { + vi.advanceTimersByTime(30000) + }) + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/VoiceMaterialLibrary.test.tsx b/apps/web/src/test/pages/VoiceMaterialLibrary.test.tsx new file mode 100644 index 000000000..dddf859f1 --- /dev/null +++ b/apps/web/src/test/pages/VoiceMaterialLibrary.test.tsx @@ -0,0 +1,225 @@ +import React from "react" +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { render, act } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" + +// Hoisted mock icons factory - must be before all vi.mock calls +const hoistedIcons = vi.hoisted(() => { + const iconNames = [ + "AudioOutlined", + "PlayCircleOutlined", + "PauseCircleOutlined", + "SearchOutlined", + "PlusOutlined", + "EditOutlined", + "DeleteOutlined", + "UploadOutlined", + "UnorderedListOutlined", + "AppstoreOutlined", + "CloseOutlined", + "SoundOutlined", + "UserOutlined", + "ManOutlined", + "WomanOutlined", + "CheckOutlined", + "TagsOutlined", + "MutedOutlined", + "RobotOutlined", + "LoadingOutlined", + "FolderOutlined", + "FolderAddOutlined", + "MoreOutlined", + "ExperimentOutlined", + "ExclamationCircleOutlined", + "DownloadOutlined", + "InboxOutlined", + "VideoCameraOutlined", + "PictureOutlined", + "SaveOutlined", + "UndoOutlined", + "RedoOutlined", + "SettingOutlined", + "HistoryOutlined", + "FilterOutlined", + "FontColorsOutlined", + "BgColorsOutlined", + "MusicOutlined", + "ScissorOutlined", + "ThunderboltOutlined", + "BulbOutlined", + "FundOutlined", + "LayoutOutlined", + "ColumnHeightOutlined", + "SwapOutlined", + "LeftOutlined", + "RightOutlined", + "UpOutlined", + "DownOutlined", + "CopyOutlined", + ] + const icons: Record = {} + iconNames.forEach((name) => { + icons[name] = () => React.createElement("span", null, name.charAt(0)) + }) + return icons +}) + +// === React Query mock === +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(() => ({ + data: undefined, + isLoading: false, + isError: false, + refetch: vi.fn(), + })), + useMutation: vi.fn(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isLoading: false, + })), + useQueryClient: vi.fn(() => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + getQueryData: vi.fn(), + })), + useInfiniteQuery: vi.fn(() => ({ + data: { pages: [] }, + isLoading: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + })), +})) + +// === Ant Design Icons mock === +vi.mock("@ant-design/icons", () => hoistedIcons) + +// === Ant Design mock === +vi.mock("antd", () => ({ + message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, + Modal: ({ open, children, title }: any) => + open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null, + Progress: () => React.createElement("div"), + Popover: ({ children }: any) => React.createElement("span", null, children), + Popconfirm: ({ children }: any) => React.createElement("span", null, children), + Tooltip: ({ children }: any) => React.createElement("span", null, children), + Tabs: () => React.createElement("div"), + Drawer: ({ open, children }: any) => + open ? React.createElement("div", { role: "dialog" }, children) : null, + Upload: ({ children }: any) => React.createElement("div", null, children), + Slider: () => React.createElement("div"), + Switch: () => React.createElement("input", { type: "checkbox" }), + Segmented: () => React.createElement("div"), + Spin: () => React.createElement("div", null, "Loading"), + Empty: () => React.createElement("div", null, "Empty"), + Divider: () => React.createElement("hr"), + Space: ({ children }: any) => React.createElement("div", null, children), + Dropdown: ({ children }: any) => React.createElement("span", null, children), + Menu: () => React.createElement("div"), + Badge: ({ children }: any) => React.createElement("span", null, children), + Radio: ({ children }: any) => React.createElement("span", null, children), + RadioGroup: ({ children }: any) => React.createElement("div", null, children), + Checkbox: ({ children }: any) => React.createElement("span", null, children), + InputNumber: () => React.createElement("input", { type: "number" }), + Form: ({ children }: any) => React.createElement("form", null, children), + FormItem: ({ children }: any) => React.createElement("div", null, children), + Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title), + List: () => React.createElement("div"), + Table: () => React.createElement("div"), + Pagination: () => React.createElement("div"), + Card: ({ children }: any) => React.createElement("div", null, children), + Avatar: ({ children }: any) => React.createElement("span", null, children), + Collapse: ({ children }: any) => React.createElement("div", null, children), + CollapsePanel: ({ children }: any) => React.createElement("div", null, children), + Tag: ({ children }: any) => React.createElement("span", null, children), + Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children), + Input: ({ placeholder }: any) => React.createElement("input", { placeholder }), + Select: ({ children }: any) => React.createElement("select", null, children), + Option: ({ children }: any) => React.createElement("option", null, children), + ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children), + TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }), +})) + +// === UI Components mock === +vi.mock("@/components/ui", () => ({ + Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children), + Input: ({ placeholder }: any) => React.createElement("input", { placeholder }), + Modal: ({ open, children }: any) => + open ? React.createElement("div", { role: "dialog" }, children) : null, + Drawer: ({ open, children }: any) => + open ? React.createElement("div", { role: "dialog" }, children) : null, + Empty: () => React.createElement("div", null, "Empty"), + Card: ({ children }: any) => React.createElement("div", null, children), + Tag: ({ children }: any) => React.createElement("span", null, children), + Tooltip: ({ children }: any) => React.createElement("span", null, children), + Select: ({ children }: any) => React.createElement("select", null, children), + Progress: () => React.createElement("div"), + Upload: ({ children }: any) => React.createElement("div", null, children), + Slider: () => React.createElement("div"), + Switch: () => React.createElement("input", { type: "checkbox" }), +})) + +// === API mocks === +vi.mock("@/api/assets", () => ({ + getAssetLibraries: vi.fn().mockResolvedValue([]), + createAssetLibrary: vi.fn().mockResolvedValue({}), + deleteAssetLibrary: vi.fn().mockResolvedValue({}), + getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }), + getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }), + createAsset: vi.fn().mockResolvedValue({}), + updateAsset: vi.fn().mockResolvedValue({}), + deleteAsset: vi.fn().mockResolvedValue({}), + uploadAssetDirect: vi.fn().mockResolvedValue({}), + batchDeleteAssets: vi.fn().mockResolvedValue({}), + batchTagAssets: vi.fn().mockResolvedValue({}), + AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" }, +})) + +vi.mock("@/api/tags", () => ({ + getTags: vi.fn().mockResolvedValue({ items: [] }), + createTag: vi.fn().mockResolvedValue({}), + tagAsset: vi.fn().mockResolvedValue({}), + untagAsset: vi.fn().mockResolvedValue({}), +})) + +vi.mock("@/api/tts", () => ({ + synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }), + getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }), + saveTtsToLibrary: vi.fn().mockResolvedValue({}), +})) + +vi.mock("@/api/voices", () => ({ + fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }), +})) + +// === PageHead mock === +vi.mock("@/components/layout/PageHead", () => ({ + default: ({ title }: { title: string }) => + React.createElement("div", { "data-testid": "page-head" }, title), +})) + +import VoiceMaterialLibrary from "@/pages/voice-materials/VoiceMaterialLibrary" + +describe("VoiceMaterialLibrary", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("renders without crashing", () => { + const { container } = render( + React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)), + ) + expect(container).toBeTruthy() + }) + + it("advances timers without errors", () => { + render(React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary))) + act(() => { + vi.advanceTimersByTime(30000) + }) + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/hooks/useEditPlanClips.test.ts b/apps/web/src/test/pages/editing-planner/hooks/useEditPlanClips.test.ts new file mode 100644 index 000000000..bed4f6bd1 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/hooks/useEditPlanClips.test.ts @@ -0,0 +1,493 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" +import { renderHook, act } from "@testing-library/react" + +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(() => ({ + data: { items: [], total: 0 }, + isLoading: false, + isError: false, + refetch: vi.fn(), + })), + useMutation: vi.fn(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + onSuccess: undefined, + onError: undefined, + })), + useQueryClient: vi.fn(() => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + getQueryData: vi.fn(), + })), +})) + +vi.mock("antd", () => ({ + message: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, +})) + +vi.mock("@/api/editPlans", () => ({ + getEditPlanClips: vi.fn().mockResolvedValue({ items: [], total: 0 }), + createEditPlanClip: vi.fn().mockResolvedValue({}), + updateEditPlanClip: vi.fn().mockResolvedValue({}), + deleteEditPlanClip: vi.fn().mockResolvedValue({}), + reorderEditPlanClips: vi.fn().mockResolvedValue({}), + batchDeleteEditPlanClips: vi.fn().mockResolvedValue({ deleted_count: 0 }), + createClipsFromAssets: vi.fn().mockResolvedValue({ created_count: 0 }), +})) + +vi.mock("./useUndoRedo", () => ({ + useUndoRedo: vi.fn(() => ({ + state: [], + set: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + canUndo: false, + canRedo: false, + reset: vi.fn(), + })), +})) + +import { useEditPlanClips } from "@/pages/editing-planner/hooks/useEditPlanClips" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" + +describe("useEditPlanClips", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns default state with planId", () => { + vi.mocked(useQuery).mockReturnValue({ + data: { items: [], total: 0 }, + isLoading: false, + isError: false, + refetch: vi.fn(), + } as any) + + const { result } = renderHook(() => useEditPlanClips("plan-123")) + + expect(result.current.clips).toEqual([]) + expect(result.current.clipsTotal).toBe(0) + expect(result.current.clipsLoading).toBe(false) + expect(result.current.selectedClipId).toBeNull() + expect(result.current.selectedClip).toBeNull() + }) + + it("returns clips from query data", () => { + const mockClips = [ + { id: "clip-1", type: "video", asset_id: "a1", order: 0 }, + { id: "clip-2", type: "video", asset_id: "a2", order: 1 }, + ] + vi.mocked(useQuery).mockReturnValue({ + data: { items: mockClips, total: 2 }, + isLoading: false, + isError: false, + refetch: vi.fn(), + } as any) + + const { result } = renderHook(() => useEditPlanClips("plan-123")) + + expect(result.current.clips).toHaveLength(2) + expect(result.current.clipsTotal).toBe(2) + expect(result.current.clips[0].id).toBe("clip-1") + }) + + it("handles loading state", () => { + vi.mocked(useQuery).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: vi.fn(), + } as any) + + const { result } = renderHook(() => useEditPlanClips("plan-123")) + + expect(result.current.clipsLoading).toBe(true) + expect(result.current.clips).toEqual([]) + }) + + it("disables query when no planId", () => { + const { result } = renderHook(() => useEditPlanClips(undefined)) + expect(result.current.clips).toEqual([]) + }) + + it("setSelectedClipId updates selection", () => { + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.setSelectedClipId("clip-1") + }) + + expect(result.current.selectedClipId).toBe("clip-1") + }) + + it("selectedClip finds matching clip", () => { + const mockClips = [{ id: "clip-1", type: "video", order: 0 }] + vi.mocked(useQuery).mockReturnValue({ + data: { items: mockClips, total: 1 }, + isLoading: false, + isError: false, + refetch: vi.fn(), + } as any) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.setSelectedClipId("clip-1") + }) + + expect(result.current.selectedClip?.id).toBe("clip-1") + }) + + it("selectedClip returns null when no match", () => { + vi.mocked(useQuery).mockReturnValue({ + data: { items: [{ id: "c1", order: 0 }], total: 1 }, + isLoading: false, + isError: false, + refetch: vi.fn(), + } as any) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.setSelectedClipId("nonexistent") + }) + + expect(result.current.selectedClip).toBeNull() + }) + + it("addClip calls createMutation with order", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockImplementation((options: any) => { + // 捕获 onSuccess/onError 回调 + return { + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + } + }) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.addClip({ type: "video", asset_id: "a1" }) + }) + + expect(mockMutate).toHaveBeenCalled() + }) + + it("addClip does nothing when no planId", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + } as any) + + const { result } = renderHook(() => useEditPlanClips(undefined)) + + act(() => { + result.current.addClip({ type: "video", asset_id: "a1" }) + }) + + expect(mockMutate).not.toHaveBeenCalled() + }) + + it("removeClip calls deleteMutation", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockImplementation((options: any) => ({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + })) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.removeClip("clip-1") + }) + + expect(mockMutate).toHaveBeenCalledWith("clip-1") + }) + + it("removeClip clears selection if selected clip is deleted", () => { + vi.mocked(useMutation).mockImplementation((options: any) => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + })) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.setSelectedClipId("clip-1") + }) + expect(result.current.selectedClipId).toBe("clip-1") + + act(() => { + result.current.removeClip("clip-1") + }) + + expect(result.current.selectedClipId).toBeNull() + }) + + it("removeClip does nothing when no planId", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + } as any) + + const { result } = renderHook(() => useEditPlanClips(undefined)) + + act(() => { + result.current.removeClip("clip-1") + }) + + expect(mockMutate).not.toHaveBeenCalled() + }) + + it("updateClip calls updateMutation", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockImplementation((options: any) => ({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + })) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.updateClip("clip-1", { duration: 10 }) + }) + + expect(mockMutate).toHaveBeenCalledWith({ + clipId: "clip-1", + data: { duration: 10 }, + }) + }) + + it("updateClip does nothing when no planId", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + } as any) + + const { result } = renderHook(() => useEditPlanClips(undefined)) + + act(() => { + result.current.updateClip("c1", {}) + }) + + expect(mockMutate).not.toHaveBeenCalled() + }) + + it("batchRemoveClips calls batchDeleteMutation", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockImplementation((options: any) => ({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + })) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.batchRemoveClips(["clip-1", "clip-2"]) + }) + + expect(mockMutate).toHaveBeenCalledWith(["clip-1", "clip-2"]) + }) + + it("batchRemoveClips does nothing with empty array", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + } as any) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.batchRemoveClips([]) + }) + + expect(mockMutate).not.toHaveBeenCalled() + }) + + it("batchRemoveClips clears selection if selected is in batch", () => { + vi.mocked(useMutation).mockImplementation((options: any) => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + })) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.setSelectedClipId("clip-1") + }) + + act(() => { + result.current.batchRemoveClips(["clip-1", "clip-2"]) + }) + + expect(result.current.selectedClipId).toBeNull() + }) + + it("reorderClips calls reorderMutation", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockImplementation((options: any) => ({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + })) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.reorderClips([{ id: "c1", order: 0 }]) + }) + + expect(mockMutate).toHaveBeenCalledWith([{ id: "c1", order: 0 }]) + }) + + it("reorderClips does nothing with empty items", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + } as any) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.reorderClips([]) + }) + + expect(mockMutate).not.toHaveBeenCalled() + }) + + it("importFromAssets calls importMutation", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockImplementation((options: any) => ({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + })) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.importFromAssets(["asset-1", "asset-2"]) + }) + + expect(mockMutate).toHaveBeenCalledWith(["asset-1", "asset-2"]) + }) + + it("importFromAssets does nothing with empty array", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + } as any) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + act(() => { + result.current.importFromAssets([]) + }) + + expect(mockMutate).not.toHaveBeenCalled() + }) + + it("importFromAssets does nothing when no planId", () => { + const mockMutate = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + } as any) + + const { result } = renderHook(() => useEditPlanClips(undefined)) + + act(() => { + result.current.importFromAssets(["a1"]) + }) + + expect(mockMutate).not.toHaveBeenCalled() + }) + + it("returns local undo redo state", () => { + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + expect(result.current.localClips).toEqual([]) + expect(typeof result.current.setLocalClips).toBe("function") + expect(typeof result.current.undo).toBe("function") + expect(typeof result.current.redo).toBe("function") + expect(result.current.canUndo).toBe(false) + expect(result.current.canRedo).toBe(false) + expect(typeof result.current.resetLocalClips).toBe("function") + }) + + it("exposes mutation status flags", () => { + vi.mocked(useMutation).mockImplementation((options: any) => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isLoading: false, + isError: false, + })) + + const { result } = renderHook(() => useEditPlanClips("plan-1")) + + expect(result.current.isCreating).toBe(false) + expect(result.current.isUpdating).toBe(false) + expect(result.current.isDeleting).toBe(false) + expect(result.current.isReordering).toBe(false) + expect(result.current.isImporting).toBe(false) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/types/subtitle.test.ts b/apps/web/src/test/pages/editing-planner/types/subtitle.test.ts new file mode 100644 index 000000000..8f060a084 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/types/subtitle.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" +import { + DEFAULT_SUBTITLE_STYLE, + type SubtitleStyleConfig, + type SubtitleMode, +} from "@/pages/editing-planner/types/subtitle" + +describe("subtitle types & defaults", () => { + it("DEFAULT_SUBTITLE_STYLE has correct shape", () => { + expect(DEFAULT_SUBTITLE_STYLE).toMatchObject({ + enabled: true, + mode: "asr", + fontSize: 16, + fontColor: "#ffffff", + stroke: true, + shadow: false, + position: "bottom", + font: "思源黑体", + animation: "none", + asrLanguage: "zh", + }) + }) + + it("DEFAULT_SUBTITLE_STYLE enabled is boolean", () => { + expect(typeof DEFAULT_SUBTITLE_STYLE.enabled).toBe("boolean") + }) + + it("DEFAULT_SUBTITLE_STYLE fontSize is number", () => { + expect(typeof DEFAULT_SUBTITLE_STYLE.fontSize).toBe("number") + expect(DEFAULT_SUBTITLE_STYLE.fontSize).toBeGreaterThan(0) + }) + + it("DEFAULT_SUBTITLE_STYLE position is valid", () => { + expect(["top", "center", "bottom"]).toContain(DEFAULT_SUBTITLE_STYLE.position) + }) + + it("DEFAULT_SUBTITLE_STYLE mode is valid SubtitleMode", () => { + const mode: SubtitleMode = DEFAULT_SUBTITLE_STYLE.mode + expect(["manual", "asr"]).toContain(mode) + }) + + it("DEFAULT_SUBTITLE_STYLE asrLanguage is valid", () => { + expect(["zh", "en"]).toContain(DEFAULT_SUBTITLE_STYLE.asrLanguage) + }) + + it("DEFAULT_SUBTITLE_STYLE has all required fields", () => { + const keys = Object.keys(DEFAULT_SUBTITLE_STYLE) + expect(keys.length).toBeGreaterThanOrEqual(10) + expect(keys).toContain("enabled") + expect(keys).toContain("mode") + expect(keys).toContain("fontSize") + expect(keys).toContain("fontColor") + expect(keys).toContain("stroke") + expect(keys).toContain("shadow") + expect(keys).toContain("position") + expect(keys).toContain("font") + expect(keys).toContain("animation") + expect(keys).toContain("asrLanguage") + }) + + it("fontColor is valid hex color", () => { + expect(DEFAULT_SUBTITLE_STYLE.fontColor).toMatch(/^#[0-9a-fA-F]{6}$/) + }) + + it("animation is string", () => { + expect(typeof DEFAULT_SUBTITLE_STYLE.animation).toBe("string") + }) + + it("font is non-empty string", () => { + expect(typeof DEFAULT_SUBTITLE_STYLE.font).toBe("string") + expect(DEFAULT_SUBTITLE_STYLE.font.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/web/src/test/router/index.test.tsx b/apps/web/src/test/router/index.test.tsx new file mode 100644 index 000000000..67f130cfe --- /dev/null +++ b/apps/web/src/test/router/index.test.tsx @@ -0,0 +1,224 @@ +import React from "react" +import { describe, expect, it, vi, beforeEach } from "vitest" +import { render, screen } from "@testing-library/react" +import { MemoryRouter, Routes, Route, Navigate } from "react-router-dom" + +vi.mock("@/store/authStore", () => ({ + useAuthStore: vi.fn((selector: (state: any) => unknown) => + selector({ + isAuthenticated: false, + user: null, + accessToken: null, + refreshToken: null, + clearAuth: vi.fn(), + setAuth: vi.fn(), + }), + ), +})) + +vi.mock("@/pages/home/HomePage", () => ({ + default: () =>
Home
, +})) + +vi.mock("@/components/layout/MainLayout", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +import { useAuthStore } from "@/store/authStore" + +// 模拟 ProtectedRoute 逻辑(和 router/index.tsx 一致) +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated) + const hasAccessToken = Boolean(localStorage.getItem("access_token")) + + if (!isAuthenticated || !hasAccessToken) { + return + } + + return <>{children} +} + +const HomeRoute = () => { + const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated) + const hasAccessToken = Boolean(localStorage.getItem("access_token")) + + if (isAuthenticated && hasAccessToken) { + return + } + + return
Home
+} + +describe("router - ProtectedRoute", () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it("redirects to login when not authenticated", () => { + vi.mocked(useAuthStore).mockImplementation((selector: any) => + selector({ isAuthenticated: false }), + ) + + render( + + + +
Protected
+ + } + /> + Login
} /> + + , + ) + + expect(screen.getByTestId("login")).toBeTruthy() + }) + + it("redirects to login when authenticated but no token", () => { + vi.mocked(useAuthStore).mockImplementation((selector: any) => + selector({ isAuthenticated: true }), + ) + localStorage.removeItem("access_token") + + render( + + + +
Protected
+ + } + /> + Login
} /> + + , + ) + + expect(screen.getByTestId("login")).toBeTruthy() + }) + + it("renders children when authenticated and has token", () => { + vi.mocked(useAuthStore).mockImplementation((selector: any) => + selector({ isAuthenticated: true }), + ) + localStorage.setItem("access_token", "test-token") + + render( + + + +
Protected
+ + } + /> + Login} /> +
+
, + ) + + expect(screen.getByTestId("protected")).toBeTruthy() + }) +}) + +describe("router - HomeRoute", () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it("shows home page when not authenticated", () => { + vi.mocked(useAuthStore).mockImplementation((selector: any) => + selector({ isAuthenticated: false }), + ) + + render( + + + } /> + Dashboard} /> + + , + ) + + expect(screen.getByTestId("home-page")).toBeTruthy() + }) + + it("redirects to dashboard when authenticated with token", () => { + vi.mocked(useAuthStore).mockImplementation((selector: any) => + selector({ isAuthenticated: true }), + ) + localStorage.setItem("access_token", "test-token") + + render( + + + } /> + Dashboard} /> + + , + ) + + expect(screen.getByTestId("dashboard")).toBeTruthy() + }) + + it("shows home when authenticated but no localStorage token", () => { + vi.mocked(useAuthStore).mockImplementation((selector: any) => + selector({ isAuthenticated: true }), + ) + + render( + + + } /> + Dashboard} /> + + , + ) + + expect(screen.getByTestId("home-page")).toBeTruthy() + }) +}) + +describe("router config", () => { + it("exports router", async () => { + const mod = await import("@/router") + expect(mod.router).toBeDefined() + }) + + it("router has correct number of top-level routes", async () => { + const mod = await import("@/router") + const routes = mod.router.routes + expect(Array.isArray(routes)).toBe(true) + expect(routes.length).toBeGreaterThan(5) + }) + + it("includes login route", async () => { + const mod = await import("@/router") + const loginRoute = mod.router.routes.find((r: any) => r.path === "/login") + expect(loginRoute).toBeDefined() + }) + + it("includes register route", async () => { + const mod = await import("@/router") + const registerRoute = mod.router.routes.find((r: any) => r.path === "/register") + expect(registerRoute).toBeDefined() + }) + + it("includes root route", async () => { + const mod = await import("@/router") + const rootRoute = mod.router.routes.find((r: any) => r.path === "/") + expect(rootRoute).toBeDefined() + }) +}) -- 2.54.0 From 8b566837a22c9fdcdc65730f2abd40c6b367a1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sun, 19 Jul 2026 15:55:06 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E6=B5=8B=E8=AF=95=E7=9B=AE=E5=BD=95?= =?UTF-8?q?eslint=E5=85=B3=E9=97=ADno-non-null-asserted-optional-chain?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/test/.eslintrc.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/test/.eslintrc.cjs b/apps/web/src/test/.eslintrc.cjs index 38b269e77..b77325fb6 100644 --- a/apps/web/src/test/.eslintrc.cjs +++ b/apps/web/src/test/.eslintrc.cjs @@ -7,5 +7,6 @@ module.exports = { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-non-null-asserted-optional-chain": "off", }, } -- 2.54.0 From 1d2a2083f71e839d43ebcbb2a7c936e17df31ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sun, 19 Jul 2026 17:06:52 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20router=E6=B5=8B=E8=AF=95=E5=8A=A8?= =?UTF-8?q?=E6=80=81import=E6=94=B9=E9=9D=99=E6=80=81import=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DCI=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/test/router/index.test.tsx | 26 +++++++++++-------------- 1 file changed, 11 insertions(+), 15 deletions(-) mode change 100644 => 100755 apps/web/src/test/router/index.test.tsx diff --git a/apps/web/src/test/router/index.test.tsx b/apps/web/src/test/router/index.test.tsx old mode 100644 new mode 100755 index 67f130cfe..518203015 --- a/apps/web/src/test/router/index.test.tsx +++ b/apps/web/src/test/router/index.test.tsx @@ -27,6 +27,7 @@ vi.mock("@/components/layout/MainLayout", () => ({ })) import { useAuthStore } from "@/store/authStore" +import { router } from "@/router" // 模拟 ProtectedRoute 逻辑(和 router/index.tsx 一致) const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { @@ -192,33 +193,28 @@ describe("router - HomeRoute", () => { }) describe("router config", () => { - it("exports router", async () => { - const mod = await import("@/router") - expect(mod.router).toBeDefined() + it("exports router", () => { + expect(router).toBeDefined() }) - it("router has correct number of top-level routes", async () => { - const mod = await import("@/router") - const routes = mod.router.routes + it("router has correct number of top-level routes", () => { + const routes = router.routes expect(Array.isArray(routes)).toBe(true) expect(routes.length).toBeGreaterThan(5) }) - it("includes login route", async () => { - const mod = await import("@/router") - const loginRoute = mod.router.routes.find((r: any) => r.path === "/login") + it("includes login route", () => { + const loginRoute = router.routes.find((r: any) => r.path === "/login") expect(loginRoute).toBeDefined() }) - it("includes register route", async () => { - const mod = await import("@/router") - const registerRoute = mod.router.routes.find((r: any) => r.path === "/register") + it("includes register route", () => { + const registerRoute = router.routes.find((r: any) => r.path === "/register") expect(registerRoute).toBeDefined() }) - it("includes root route", async () => { - const mod = await import("@/router") - const rootRoute = mod.router.routes.find((r: any) => r.path === "/") + it("includes root route", () => { + const rootRoute = router.routes.find((r: any) => r.path === "/") expect(rootRoute).toBeDefined() }) }) -- 2.54.0