|
|
|
@ -30,12 +30,25 @@ vi.mock("@/hooks/useSelectedNodeCount", () => ({ |
|
|
|
|
|
|
|
// Mock the workflow store
|
|
|
|
const mockUpdateNodeData = vi.fn(); |
|
|
|
const mockUpdateMediaNodeData = vi.fn(); |
|
|
|
const mockRegenerateNode = vi.fn(); |
|
|
|
const mockAddNode = vi.fn(); |
|
|
|
const mockIncrementModalCount = vi.fn(); |
|
|
|
const mockDecrementModalCount = vi.fn(); |
|
|
|
const mockUseWorkflowStore = vi.fn(); |
|
|
|
const mockUseModelStore = vi.fn(); |
|
|
|
const mockFetchModelDetail = vi.fn((_modelId: string) => |
|
|
|
Promise.resolve({ parameters: [], inputs: [] }) |
|
|
|
); |
|
|
|
// 选中态动作胶囊现在依赖 nodeToolStore.soleSelectedNodeId(布尔等值订阅),
|
|
|
|
// 不再依赖 workflowStore 的选中数量。测试通过改写这份可变状态来模拟“唯一选中”。
|
|
|
|
const mockNodeToolState = { |
|
|
|
soleSelectedNodeId: null as string | null, |
|
|
|
activeNodeId: null as string | null, |
|
|
|
activeTool: null as unknown, |
|
|
|
visible: false, |
|
|
|
switchTool: vi.fn(), |
|
|
|
}; |
|
|
|
let mockNodes: Array<{ id: string; selected?: boolean; type?: string; data?: Record<string, unknown> }> = []; |
|
|
|
let mockPopiserverModelLists = { |
|
|
|
image: { models: [] as ProviderModel[], loading: false, error: null, loadedAt: null }, |
|
|
|
@ -71,10 +84,20 @@ vi.mock("@/store/modelStore", () => ({ |
|
|
|
detailsById: {}, |
|
|
|
detailLoadingById: {}, |
|
|
|
detailErrorById: {}, |
|
|
|
fetchModelDetail: vi.fn(), |
|
|
|
fetchModelDetail: mockFetchModelDetail, |
|
|
|
}), |
|
|
|
})); |
|
|
|
|
|
|
|
vi.mock("@/store/nodeToolStore", async () => { |
|
|
|
const actual = await vi.importActual<typeof import("@/store/nodeToolStore")>( |
|
|
|
"@/store/nodeToolStore" |
|
|
|
); |
|
|
|
return { |
|
|
|
...actual, |
|
|
|
useNodeToolStore: (selector: (state: unknown) => unknown) => selector(mockNodeToolState), |
|
|
|
}; |
|
|
|
}); |
|
|
|
|
|
|
|
vi.mock("@/store/workflowStore", () => ({ |
|
|
|
useWorkflowStore: (selector?: (state: unknown) => unknown) => { |
|
|
|
if (selector) { |
|
|
|
@ -163,6 +186,13 @@ describe("GenerateVideoNode", () => { |
|
|
|
}; |
|
|
|
mockNodes = []; |
|
|
|
mockSelectedNodeCountRef.current = 0; |
|
|
|
mockNodeToolState.soleSelectedNodeId = null; |
|
|
|
mockNodeToolState.activeNodeId = null; |
|
|
|
mockNodeToolState.activeTool = null; |
|
|
|
mockNodeToolState.visible = false; |
|
|
|
mockFetchModelDetail.mockImplementation((_modelId: string) => |
|
|
|
Promise.resolve({ parameters: [], inputs: [] }) |
|
|
|
); |
|
|
|
mockGetVideoDimensions.mockResolvedValue({ width: 1600, height: 900 }); |
|
|
|
mockFetch.mockResolvedValue({ |
|
|
|
ok: true, |
|
|
|
@ -175,6 +205,7 @@ describe("GenerateVideoNode", () => { |
|
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
|
const state = { |
|
|
|
updateNodeData: mockUpdateNodeData, |
|
|
|
updateMediaNodeData: mockUpdateMediaNodeData, |
|
|
|
regenerateNode: mockRegenerateNode, |
|
|
|
addNode: mockAddNode, |
|
|
|
incrementModalCount: mockIncrementModalCount, |
|
|
|
@ -230,6 +261,7 @@ describe("GenerateVideoNode", () => { |
|
|
|
const selectCurrentNode = () => { |
|
|
|
mockNodes = [{ id: "test-node-1", type: "generateVideo", data: createNodeData(), selected: true }]; |
|
|
|
mockSelectedNodeCountRef.current = 1; |
|
|
|
mockNodeToolState.soleSelectedNodeId = "test-node-1"; |
|
|
|
}; |
|
|
|
|
|
|
|
describe("Basic Rendering", () => { |
|
|
|
@ -444,19 +476,6 @@ describe("GenerateVideoNode", () => { |
|
|
|
expect(mockSetNodes).not.toHaveBeenCalled(); |
|
|
|
}); |
|
|
|
|
|
|
|
it("should render video with controls attribute", () => { |
|
|
|
const { container } = render( |
|
|
|
<TestWrapper> |
|
|
|
<GenerateVideoNode {...createNodeProps({ |
|
|
|
outputVideo: "data:video/mp4;base64,abc123", |
|
|
|
})} /> |
|
|
|
</TestWrapper> |
|
|
|
); |
|
|
|
|
|
|
|
const video = container.querySelector("video"); |
|
|
|
expect(video).toHaveAttribute("controls"); |
|
|
|
}); |
|
|
|
|
|
|
|
it("should render video with loop attribute", () => { |
|
|
|
const { container } = render( |
|
|
|
<TestWrapper> |
|
|
|
@ -587,13 +606,17 @@ describe("GenerateVideoNode", () => { |
|
|
|
fireEvent.click(getRemoveVideoButton()); |
|
|
|
|
|
|
|
await waitFor(() => { |
|
|
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({ |
|
|
|
outputVideo: secondVideo.video, |
|
|
|
videoHistory: [secondVideo], |
|
|
|
selectedVideoHistoryIndex: 0, |
|
|
|
status: "idle", |
|
|
|
error: null, |
|
|
|
})); |
|
|
|
expect(mockUpdateMediaNodeData).toHaveBeenCalledWith( |
|
|
|
"test-node-1", |
|
|
|
expect.objectContaining({ |
|
|
|
outputVideo: secondVideo.video, |
|
|
|
videoHistory: [secondVideo], |
|
|
|
selectedVideoHistoryIndex: 0, |
|
|
|
status: "idle", |
|
|
|
error: null, |
|
|
|
}), |
|
|
|
null, |
|
|
|
); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
@ -636,6 +659,7 @@ describe("GenerateVideoNode", () => { |
|
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
|
const state = { |
|
|
|
updateNodeData: mockUpdateNodeData, |
|
|
|
updateMediaNodeData: mockUpdateMediaNodeData, |
|
|
|
regenerateNode: mockRegenerateNode, |
|
|
|
addNode: mockAddNode, |
|
|
|
incrementModalCount: mockIncrementModalCount, |
|
|
|
@ -676,6 +700,7 @@ describe("GenerateVideoNode", () => { |
|
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
|
const state = { |
|
|
|
updateNodeData: mockUpdateNodeData, |
|
|
|
updateMediaNodeData: mockUpdateMediaNodeData, |
|
|
|
regenerateNode: mockRegenerateNode, |
|
|
|
addNode: mockAddNode, |
|
|
|
incrementModalCount: mockIncrementModalCount, |
|
|
|
@ -710,12 +735,16 @@ describe("GenerateVideoNode", () => { |
|
|
|
|
|
|
|
fireEvent.click(screen.getByTitle("Next video")); |
|
|
|
|
|
|
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", { |
|
|
|
outputVideo: "data:video/mp4;base64,second", |
|
|
|
selectedVideoHistoryIndex: 1, |
|
|
|
status: "idle", |
|
|
|
error: null, |
|
|
|
}); |
|
|
|
expect(mockUpdateMediaNodeData).toHaveBeenCalledWith( |
|
|
|
"test-node-1", |
|
|
|
expect.objectContaining({ |
|
|
|
outputVideo: "data:video/mp4;base64,second", |
|
|
|
selectedVideoHistoryIndex: 1, |
|
|
|
status: "idle", |
|
|
|
error: null, |
|
|
|
}), |
|
|
|
null, |
|
|
|
); |
|
|
|
}); |
|
|
|
|
|
|
|
it("should show current position in carousel", () => { |
|
|
|
@ -928,35 +957,15 @@ describe("GenerateVideoNode", () => { |
|
|
|
expect(screen.queryByLabelText("Sound")).not.toBeInTheDocument(); |
|
|
|
}); |
|
|
|
|
|
|
|
it("should load hidden schema without resizing the node body", async () => { |
|
|
|
it("does not resize the node body on rerender when a model is selected", async () => { |
|
|
|
// 节点已不再在挂载时拉取 schema(useSelectedModelSchema enabled:false),
|
|
|
|
// 因此选中模型 + 重渲染都不应触发节点尺寸变化(setNodes)。
|
|
|
|
window.localStorage.removeItem("node-banana-schema-cache"); |
|
|
|
mockSetNodes.mockClear(); |
|
|
|
mockFetch.mockImplementation((url) => { |
|
|
|
const requestUrl = String(url); |
|
|
|
if (requestUrl.startsWith("/api/models?")) { |
|
|
|
return Promise.resolve({ |
|
|
|
ok: true, |
|
|
|
json: () => Promise.resolve({ models: [] }), |
|
|
|
}); |
|
|
|
} |
|
|
|
if (requestUrl.startsWith("/api/models/")) { |
|
|
|
return Promise.resolve({ |
|
|
|
ok: true, |
|
|
|
json: () => Promise.resolve({ |
|
|
|
parameters: [{ name: "motion", type: "string", label: "Motion" }], |
|
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], |
|
|
|
}), |
|
|
|
}); |
|
|
|
} |
|
|
|
return Promise.resolve({ |
|
|
|
ok: true, |
|
|
|
json: () => Promise.resolve({}), |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
const props = createNodeProps({ |
|
|
|
selectedModel: { |
|
|
|
provider: "newapiwg", |
|
|
|
provider: "popiserver", |
|
|
|
modelId: "vidu-q1", |
|
|
|
displayName: "Vidu Q1", |
|
|
|
capabilities: ["text-to-video"], |
|
|
|
@ -968,14 +977,8 @@ describe("GenerateVideoNode", () => { |
|
|
|
<GenerateVideoNode {...props} /> |
|
|
|
</TestWrapper> |
|
|
|
); |
|
|
|
|
|
|
|
await waitFor(() => { |
|
|
|
const schemaFetchCalls = mockFetch.mock.calls.filter(call => |
|
|
|
typeof call[0] === "string" && call[0].includes("/api/models/") |
|
|
|
); |
|
|
|
expect(schemaFetchCalls.length).toBeGreaterThan(0); |
|
|
|
}); |
|
|
|
const resizeCallsAfterSchemaLoad = mockSetNodes.mock.calls.length; |
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0)); |
|
|
|
const resizeCallsAfterMount = mockSetNodes.mock.calls.length; |
|
|
|
|
|
|
|
rerender( |
|
|
|
<TestWrapper> |
|
|
|
@ -984,58 +987,10 @@ describe("GenerateVideoNode", () => { |
|
|
|
); |
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0)); |
|
|
|
|
|
|
|
expect(mockSetNodes.mock.calls.length).toBe(resizeCallsAfterSchemaLoad); |
|
|
|
expect(mockSetNodes.mock.calls.length).toBe(resizeCallsAfterMount); |
|
|
|
}); |
|
|
|
|
|
|
|
it("should load ModelParameters schema when model is selected without showing node settings", async () => { |
|
|
|
render( |
|
|
|
<TestWrapper> |
|
|
|
<GenerateVideoNode {...createNodeProps({ |
|
|
|
selectedModel: { provider: "fal", modelId: "kling-video/v1", displayName: "Kling Video" }, |
|
|
|
})} /> |
|
|
|
</TestWrapper> |
|
|
|
); |
|
|
|
|
|
|
|
await waitFor(() => { |
|
|
|
const schemaFetchCalls = mockFetch.mock.calls.filter(call => |
|
|
|
typeof call[0] === "string" && call[0].includes("/api/models/") |
|
|
|
); |
|
|
|
expect(schemaFetchCalls.length).toBeGreaterThan(0); |
|
|
|
}); |
|
|
|
expect(screen.queryByText("Settings")).not.toBeInTheDocument(); |
|
|
|
}); |
|
|
|
|
|
|
|
it("should fetch selected model schema only once", async () => { |
|
|
|
mockFetch.mockImplementation((url) => { |
|
|
|
const requestUrl = String(url); |
|
|
|
if (requestUrl.startsWith("/api/models?")) { |
|
|
|
return Promise.resolve({ |
|
|
|
ok: true, |
|
|
|
json: () => Promise.resolve({ |
|
|
|
models: [ |
|
|
|
{ id: "32", name: "Default Popi Video", provider: "popiserver", capabilities: ["text-to-video"] }, |
|
|
|
], |
|
|
|
}), |
|
|
|
}); |
|
|
|
} |
|
|
|
if (requestUrl.startsWith("/api/models/32")) { |
|
|
|
return Promise.resolve({ |
|
|
|
ok: true, |
|
|
|
json: () => Promise.resolve({ |
|
|
|
parameters: [], |
|
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], |
|
|
|
}), |
|
|
|
}); |
|
|
|
} |
|
|
|
return Promise.resolve({ |
|
|
|
ok: true, |
|
|
|
json: () => Promise.resolve({}), |
|
|
|
}); |
|
|
|
}); |
|
|
|
mockPopiserverModelLists.video.models = [ |
|
|
|
{ id: "32", name: "Default Popi Video", provider: "popiserver", capabilities: ["text-to-video"], description: null }, |
|
|
|
]; |
|
|
|
|
|
|
|
it("does not show inline node settings when a model is selected", async () => { |
|
|
|
render( |
|
|
|
<TestWrapper> |
|
|
|
<GenerateVideoNode {...createNodeProps({ |
|
|
|
@ -1044,12 +999,8 @@ describe("GenerateVideoNode", () => { |
|
|
|
</TestWrapper> |
|
|
|
); |
|
|
|
|
|
|
|
await waitFor(() => { |
|
|
|
const detailCalls = mockFetch.mock.calls.filter((call) => |
|
|
|
String(call[0]).startsWith("/api/models/32") |
|
|
|
); |
|
|
|
expect(detailCalls).toHaveLength(1); |
|
|
|
}); |
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0)); |
|
|
|
expect(screen.queryByText("Settings")).not.toBeInTheDocument(); |
|
|
|
}); |
|
|
|
|
|
|
|
it("keeps saved popiserver default when it exists in the model list", async () => { |
|
|
|
@ -1084,12 +1035,8 @@ describe("GenerateVideoNode", () => { |
|
|
|
</TestWrapper> |
|
|
|
); |
|
|
|
|
|
|
|
await waitFor(() => { |
|
|
|
expect(mockFetch).toHaveBeenCalledWith( |
|
|
|
"/api/models/37", |
|
|
|
expect.any(Object) |
|
|
|
); |
|
|
|
}); |
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0)); |
|
|
|
// 保存的默认模型 37 存在于列表中,自动选型不应把它替换成列表首个模型 32
|
|
|
|
expect(mockUpdateNodeData).not.toHaveBeenCalledWith( |
|
|
|
"test-node-1", |
|
|
|
expect.objectContaining({ |
|
|
|
@ -1187,45 +1134,9 @@ describe("GenerateVideoNode", () => { |
|
|
|
}) |
|
|
|
); |
|
|
|
// 不应多查默认模型 32 的详情
|
|
|
|
expect(mockFetch.mock.calls.some((call) => |
|
|
|
String(call[0]).startsWith("/api/models/32") |
|
|
|
expect(mockFetchModelDetail.mock.calls.some((call) => |
|
|
|
String(call[0]) === "32" |
|
|
|
)).toBe(false); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
describe("Fetch Models on Mount", () => { |
|
|
|
it("should fetch models when provider is fal", async () => { |
|
|
|
render( |
|
|
|
<TestWrapper> |
|
|
|
<GenerateVideoNode {...createNodeProps({ |
|
|
|
selectedModel: { provider: "fal", modelId: "", displayName: "Select model..." }, |
|
|
|
})} /> |
|
|
|
</TestWrapper> |
|
|
|
); |
|
|
|
|
|
|
|
await waitFor(() => { |
|
|
|
const fetchCalls = mockFetch.mock.calls.filter(call => |
|
|
|
typeof call[0] === 'string' && call[0].includes('/api/models?') |
|
|
|
); |
|
|
|
expect(fetchCalls.length).toBeGreaterThan(0); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
it("should request video capabilities when fetching models", async () => { |
|
|
|
render( |
|
|
|
<TestWrapper> |
|
|
|
<GenerateVideoNode {...createNodeProps({ |
|
|
|
selectedModel: { provider: "fal", modelId: "", displayName: "Select model..." }, |
|
|
|
})} /> |
|
|
|
</TestWrapper> |
|
|
|
); |
|
|
|
|
|
|
|
await waitFor(() => { |
|
|
|
const fetchCalls = mockFetch.mock.calls.filter(call => |
|
|
|
typeof call[0] === 'string' && call[0].includes('text-to-video') |
|
|
|
); |
|
|
|
expect(fetchCalls.length).toBeGreaterThan(0); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|