Files
workinf_Blender_Wasm/web/tests/e2e/web-texture-paint-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

142 lines
8.6 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");
const TEXTURE = path.resolve(HERE, "../../app/public/tutorial/texture-paint/re-texture-base.png");
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 assignReMaterialAndLocalTexture(page: import("@playwright/test").Page): Promise<void> {
await clickWorkspace(page, "Shading");
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"]').count(), { timeout: 60_000 }).toBe(6);
await page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first().dblclick();
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 120_000 })
.toContain("material:black_tiles_wall");
await page.locator('.property-section input[type="file"][accept="image/png,image/jpeg"]').first().setInputFiles(TEXTURE);
await expect.poll(async () => (await page.locator('select[aria-label="材质图片"] option').allTextContents()).some((text) => text.includes("re-texture-base")), { timeout: 60_000 })
.toBe(true);
await page.locator('select[aria-label="材质图片"]').first().selectOption({ label: "re-texture-base" });
await page.waitForTimeout(2_000);
}
async function openTexturePaint(page: import("@playwright/test").Page): Promise<void> {
await clickWorkspace(page, "Texture Paint");
await page.locator(".texture-paint-view").waitFor({ timeout: 60_000 });
await page.locator('[data-testid="texture-paint-canvas"]').waitFor({ timeout: 60_000 });
await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-image-id"), { timeout: 60_000 })
.toContain("re-texture-base");
await expect.poll(async () => page.locator(".texture-paint-controls output").first().innerText(), { timeout: 60_000 })
.toContain("已加载");
}
async function centerPixel(page: import("@playwright/test").Page): Promise<[number, number, number, number] | null> {
return page.evaluate(() => {
const canvas = document.querySelector<HTMLCanvasElement>('[data-testid="texture-paint-canvas"]');
const context = canvas?.getContext("2d");
if (!canvas || !context) return null;
const data = context.getImageData(Math.floor(canvas.width * 0.5), Math.floor(canvas.height * 0.5), 1, 1).data;
return [data[0], data[1], data[2], data[3]];
});
}
async function paintRedThroughCenter(page: import("@playwright/test").Page): Promise<void> {
await page.locator('[aria-label="画笔颜色"]').fill("#ff0000");
await page.locator('[aria-label="画笔大小"]').fill("120");
const box = await page.locator('[data-testid="texture-paint-canvas"]').boundingBox();
if (!box) throw new Error("texture paint canvas has no box");
await page.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.5);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.5, { steps: 10 });
await page.mouse.up();
await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-last-action"), { timeout: 60_000 })
.toBe("笔画已提交");
}
test("Texture Paint paints a local image, commits one stroke and undoes it", async ({ page }) => {
test.setTimeout(300_000);
await openStudioAndMonkey(page);
await assignReMaterialAndLocalTexture(page);
await openTexturePaint(page);
const before = await centerPixel(page);
await paintRedThroughCenter(page);
const after = await centerPixel(page);
expect(after?.[0]).toBeGreaterThan(200);
expect(after?.[1]).toBeLessThan(40);
expect(after?.[2]).toBeLessThan(40);
await page.getByRole("button", { name: "撤销笔画", exact: true }).evaluate((element) => (element as HTMLElement).click());
await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-last-action"), { timeout: 60_000 }).toBe("已撤销");
const undone = await centerPixel(page);
expect(undone?.[0]).toBe(before?.[0]);
expect(undone?.[1]).toBe(before?.[1]);
expect(undone?.[2]).toBe(before?.[2]);
});
test("Texture Paint makes a linked library image local before painting", async ({ page }) => {
test.setTimeout(300_000);
await openStudioAndMonkey(page);
await clickWorkspace(page, "Shading");
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"]').count(), { timeout: 60_000 }).toBe(6);
await page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first().dblclick();
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 120_000 }).toContain("material:black_tiles_wall");
await clickWorkspace(page, "Texture Paint");
const view = page.locator(".texture-paint-view");
await view.waitFor({ timeout: 60_000 });
const imageSelect = view.getByLabel("纹理图像");
await expect.poll(async () => (await imageSelect.locator("option").allTextContents()).some((text) => text.includes("black_tiles_wall")), { timeout: 60_000 }).toBe(true);
await imageSelect.selectOption({ label: (await imageSelect.locator("option").allTextContents()).find((text) => text.includes("black_tiles_wall"))! });
await view.getByRole("button", { name: "设为本地图像", exact: true }).evaluate((element) => (element as HTMLElement).click());
await expect.poll(async () => view.locator(".texture-paint-last-action").innerText(), { timeout: 60_000 }).toContain("已设为本地");
const canvas = view.getByTestId("texture-paint-canvas");
await canvas.waitFor({ timeout: 60_000 });
const box = await canvas.boundingBox();
if (!box) throw new Error("texture paint canvas has no box");
await page.mouse.move(box.x + box.width * 0.4, box.y + box.height * 0.5);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.55, { steps: 12 });
await page.mouse.up();
await expect.poll(async () => view.getAttribute("data-last-action"), { timeout: 60_000 }).toBe("笔画已提交");
});
test("Texture Paint stroke survives an OPFS save and reopen", async ({ page }) => {
test.setTimeout(360_000);
await openStudioAndMonkey(page);
await assignReMaterialAndLocalTexture(page);
await openTexturePaint(page);
await paintRedThroughCenter(page);
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/);
await openTexturePaint(page);
const pixel = await centerPixel(page);
expect(pixel?.[0]).toBeGreaterThan(200);
expect(pixel?.[1]).toBeLessThan(40);
expect(pixel?.[2]).toBeLessThan(40);
});