- 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>
98 lines
6.4 KiB
TypeScript
98 lines
6.4 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");
|
|
const GLB = path.resolve(HERE, "../../../re/猴头.glb");
|
|
|
|
test.use({ viewport: { width: 1600, height: 900 } });
|
|
|
|
async function openStudioAndMonkey(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/);
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB);
|
|
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/);
|
|
}
|
|
|
|
async function clickWorkspace(page: import("@playwright/test").Page, name: string): Promise<void> {
|
|
await page.locator(".workspace-tabs button", { hasText: name }).evaluate((element) => (element as HTMLElement).click());
|
|
}
|
|
|
|
async function openCanvas(page: import("@playwright/test").Page): Promise<import("@playwright/test").Locator> {
|
|
await clickWorkspace(page, "Geometry Nodes");
|
|
const canvas = page.locator('[data-testid="geometry-node-canvas"]');
|
|
await canvas.waitFor({ timeout: 60_000 });
|
|
return canvas;
|
|
}
|
|
|
|
async function addTransformAndConnect(page: import("@playwright/test").Page, canvas: import("@playwright/test").Locator): Promise<void> {
|
|
await canvas.getByRole("button", { name: "添加节点", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
|
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 30_000 }).toBe("3");
|
|
const source = page.locator('.react-flow__node[data-id="group-input"] .react-flow__handle.source').first();
|
|
const target = page.locator('.react-flow__node[data-id="node-geometrynodetransform"] .react-flow__handle.target').first();
|
|
await expect(source).toHaveCount(1);
|
|
await expect(target).toHaveCount(1);
|
|
const from = await source.boundingBox();
|
|
const to = await target.boundingBox();
|
|
if (!from || !to) throw new Error("geometry node handles have no box");
|
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 });
|
|
await page.mouse.up();
|
|
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 30_000 }).toBe("2");
|
|
}
|
|
|
|
test("Geometry Nodes canvas adds, connects and commits a graph", async ({ page }) => {
|
|
test.setTimeout(300_000);
|
|
await openStudioAndMonkey(page);
|
|
const canvas = await openCanvas(page);
|
|
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
|
|
await addTransformAndConnect(page, canvas);
|
|
await canvas.getByRole("button", { name: "提交图", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
|
await expect.poll(async () => page.locator('[data-testid="geometry-node-graph-count"]').innerText(), { timeout: 60_000 }).toBe("1 graphs");
|
|
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
|
|
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("2");
|
|
const after = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
|
|
expect(after).toBeGreaterThan(before);
|
|
});
|
|
|
|
test("Geometry Nodes canvas graph survives an OPFS save and reopen", async ({ page }) => {
|
|
test.setTimeout(360_000);
|
|
await openStudioAndMonkey(page);
|
|
const canvas = await openCanvas(page);
|
|
await addTransformAndConnect(page, canvas);
|
|
const node = page.locator('.react-flow__node[data-id="node-geometrynodetransform"]');
|
|
const nodeBox = await node.boundingBox();
|
|
if (!nodeBox) throw new Error("geometry node has no box");
|
|
await page.mouse.move(nodeBox.x + 30, nodeBox.y + 15);
|
|
await page.mouse.down();
|
|
await page.mouse.move(nodeBox.x + 150, nodeBox.y + 85, { steps: 10 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(400);
|
|
const expectedPosition = JSON.parse(await canvas.getAttribute("data-node-positions") ?? "[]").at(-1) as { x: number; y: number };
|
|
expect(expectedPosition).toBeTruthy();
|
|
await canvas.getByRole("button", { name: "提交图", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
|
await expect.poll(async () => page.locator('[data-testid="geometry-node-graph-count"]').innerText(), { timeout: 60_000 }).toBe("1 graphs");
|
|
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
|
|
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("2");
|
|
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 7/);
|
|
const reopened = await openCanvas(page);
|
|
await expect.poll(async () => page.locator('[data-testid="geometry-node-graph-count"]').innerText(), { timeout: 60_000 }).toBe("1 graphs");
|
|
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");
|
|
const positions = JSON.parse(await reopened.getAttribute("data-node-positions") ?? "[]") as Array<{ x: number; y: number } | null>;
|
|
expect(positions.some((position) => position && Math.abs(position.x - expectedPosition.x) <= 1 && Math.abs(position.y - expectedPosition.y) <= 1)).toBe(true);
|
|
});
|