Browse Source

修复视频节点历史失败测试并简化高清选型

- 修复 GenerateVideoNode 的 12 个既有失败测试:补齐 nodeToolStore
  与 updateMediaNodeData 的 mock,断言对齐选中态胶囊/轮播新行为,
  移除已废弃的 schema eager-fetch、原生 controls、fal 拉列表等过时断言。
- chooseHighDefinitionVideoModel 直接取首个模型(store 的 byKind.videoEnhance
  已按 206 过滤),删除随之失效的 isHighDefinitionVideoModel 及本地 modelSubTypes。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feature/video-erase
Luckyu_js 1 week ago
parent
commit
b9a8006e3c
  1. 229
      src/components/__tests__/GenerateVideoNode.test.tsx
  2. 17
      src/utils/highDefinitionVideoNodes.ts

229
src/components/__tests__/GenerateVideoNode.test.tsx

@ -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);
});
});
});
});

17
src/utils/highDefinitionVideoNodes.ts

@ -18,21 +18,10 @@ export const HIGH_DEFINITION_VIDEO_FALLBACK_MODEL: SelectedModel = {
subTypes: [POPI_VIDEO_SUBTYPE_ENHANCE],
};
function modelSubTypes(model: ProviderModel): number[] {
const subTypes = model.metadata?.subTypes;
if (Array.isArray(subTypes)) {
return subTypes.map((item) => Number(item)).filter((item) => Number.isFinite(item));
}
const subType = Number(model.metadata?.subType);
return Number.isFinite(subType) ? [subType] : [];
}
export function isHighDefinitionVideoModel(model: ProviderModel): boolean {
return modelSubTypes(model).includes(POPI_VIDEO_SUBTYPE_ENHANCE);
}
export function chooseHighDefinitionVideoModel(models: ProviderModel[]): SelectedModel {
const model = models.find(isHighDefinitionVideoModel);
// models 由 store 的 byKind.videoEnhance 提供,已按画质增强子类型(206)过滤,
// 这里直接取首个即可,无需再次按子类型筛选。
const model = models[0];
return model ? toSelectedModel(model) : HIGH_DEFINITION_VIDEO_FALLBACK_MODEL;
}

Loading…
Cancel
Save