Files
workinf_Blender_Wasm/web/tests/e2e/web-compositor-canvas.spec.ts
mes123456 d0b6d2a164 feat(web): close remaining editor, layout and persistence gaps
- Area headers get a per-area editor switcher backed by a shared
  AreaEditorContext; Preferences can hide it.
- Animation camera area is a real navigable viewport aligned to the
  scene camera; Graph Editor adds channel selection, keyframe drag/
  insert/delete and interpolation.
- Geometry Nodes node locations round-trip through the native writer
  and reader, so saved graphs keep their layout.
- Texture Paint can make linked library images local via a new native
  makeImageLocal command before packing strokes.
- Compositor IMAGE and Render Layers nodes now persist to Main and
  execute against packed images / the last PBR render.
- Spreadsheet row cap raised to 250k; Outliner gains Ctrl+A and arrow/
  Home/End navigation.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-13 07:47:10 +08:00

138 lines
9.1 KiB
TypeScript

import { expect, test } from "@playwright/test";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend");
test.use({ viewport: { width: 1600, height: 900 } });
async function openStudio(page: import("@playwright/test").Page): Promise<void> {
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 120_000 });
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
}
async function openCompositor(page: import("@playwright/test").Page): Promise<import("@playwright/test").Locator> {
await page.locator(".workspace-tabs button", { hasText: "Compositing" }).evaluate((element) => (element as HTMLElement).click());
const canvas = page.getByTestId("compositor-canvas");
await canvas.waitFor({ timeout: 60_000 });
return canvas;
}
async function addNode(page: import("@playwright/test").Page, canvas: import("@playwright/test").Locator, type: string): Promise<void> {
const before = Number(await canvas.getAttribute("data-node-count") ?? "0");
await canvas.getByLabel("添加节点类型").selectOption(type);
await canvas.getByRole("button", { name: "添加节点", exact: true }).evaluate((element) => (element as HTMLElement).click());
await expect.poll(async () => Number(await canvas.getAttribute("data-node-count")), { timeout: 30_000 }).toBe(before + 1);
}
async function connect(page: import("@playwright/test").Page, from: string, to: string): Promise<void> {
const source = page.locator(`.react-flow__node[data-id="${from}"] .react-flow__handle.source`).first();
const target = page.locator(`.react-flow__node[data-id="${to}"] .react-flow__handle.target`).first();
await expect(source).toHaveCount(1);
await expect(target).toHaveCount(1);
const fromBox = await source.boundingBox();
const toBox = await target.boundingBox();
if (!fromBox || !toBox) throw new Error("compositor node handles have no box");
await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
await page.mouse.down();
await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 12 });
await page.mouse.up();
}
async function buildRgbExposureGraph(page: import("@playwright/test").Page, canvas: import("@playwright/test").Locator): Promise<void> {
await addNode(page, canvas, "CONSTANT_COLOR");
await addNode(page, canvas, "EXPOSURE");
await connect(page, "node-constant-color-1", "node-exposure-1");
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 30_000 }).toBe("1");
await connect(page, "node-exposure-1", "node-composite");
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 30_000 }).toBe("2");
await page.locator('.react-flow__node[data-id="node-exposure-1"]').click();
const exposure = canvas.locator('.compositor-canvas-inspector input[type="number"]').first();
await exposure.fill("1.5");
await exposure.blur();
}
test("Compositor canvas builds, executes and commits a graph", async ({ page }) => {
test.setTimeout(300_000);
await openStudio(page);
const canvas = await openCompositor(page);
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 30_000 }).toBe("1");
await expect(canvas.getByText("Composite", { exact: true }).first()).toBeVisible();
await buildRgbExposureGraph(page, canvas);
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
await canvas.getByRole("button", { name: "执行合成", exact: true }).click();
await expect.poll(async () => canvas.locator("header output").innerText(), { timeout: 30_000 }).toMatch(/合成完成/);
const painted = await canvas.getByTestId("compositor-result").evaluate((element) => {
const target = element as HTMLCanvasElement;
const data = target.getContext("2d")?.getImageData(0, 0, target.width, target.height).data;
return data ? { alpha: data[3], red: data[0], green: data[1], blue: data[2] } : null;
});
expect(painted).not.toBeNull();
expect(painted!.alpha).toBeGreaterThan(0);
expect(painted!.blue).toBeGreaterThan(painted!.red);
await canvas.getByRole("button", { name: "提交图", exact: true }).click();
await expect.poll(async () => Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(before);
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 30_000 }).toBe("3");
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 30_000 }).toBe("2");
});
test("Compositor IMAGE and Render Layers nodes persist through OPFS", async ({ page }) => {
test.setTimeout(300_000);
await openStudio(page);
const canvas = await openCompositor(page);
await addNode(page, canvas, "IMAGE");
await addNode(page, canvas, "RENDER_LAYER");
await connect(page, "node-image-1", "node-composite");
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 30_000 }).toBe("1");
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
await canvas.getByRole("button", { name: "提交图", exact: true }).click();
await expect.poll(async () => Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(before);
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 30_000 }).toBe("3");
await page.locator('button[aria-label="保存项目"]').evaluate((element) => (element as HTMLElement).click());
await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-status", "SUCCEEDED", { timeout: 60_000 });
await page.reload();
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await expect.poll(async () => page.locator('[data-testid="recent-projects"] option').count(), { timeout: 60_000 }).toBeGreaterThan(1);
await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 });
const discard = page.getByRole("button", { name: "不保存,继续", exact: true });
if (await discard.count()) await discard.click();
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
const reopened = await openCompositor(page);
await expect.poll(async () => reopened.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
await expect.poll(async () => reopened.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("1");
await expect(reopened.locator(".react-flow__node", { hasText: "图像 Image" })).toHaveCount(1);
await expect(reopened.locator(".react-flow__node", { hasText: "渲染层 Render Layers" })).toHaveCount(1);
});
test("Compositor canvas graph survives an OPFS save and reopen", async ({ page }) => {
test.setTimeout(360_000);
await openStudio(page);
const canvas = await openCompositor(page);
await buildRgbExposureGraph(page, canvas);
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
await canvas.getByRole("button", { name: "提交图", exact: true }).click();
await expect.poll(async () => Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(before);
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 30_000 }).toBe("3");
await page.locator('button[aria-label="保存项目"]').evaluate((element) => (element as HTMLElement).click());
await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-status", "SUCCEEDED", { timeout: 60_000 });
await page.reload();
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await expect.poll(async () => page.locator('[data-testid="recent-projects"] option').count(), { timeout: 60_000 }).toBeGreaterThan(1);
await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 });
const discard = page.getByRole("button", { name: "不保存,继续", exact: true });
if (await discard.count()) await discard.click();
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
const reopened = await openCompositor(page);
await expect.poll(async () => reopened.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
await expect.poll(async () => reopened.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("2");
await expect(reopened.locator(".react-flow__node", { hasText: "RGB 颜色" })).toHaveCount(1);
await reopened.locator('.react-flow__node', { hasText: "曝光 Exposure" }).click();
await expect(reopened.locator('.compositor-canvas-inspector input[type="number"]').first()).toHaveValue("1.5");
});