feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
- Add 11:07 Chinese-narrated lesson video, VTT, poster and 13 chapter screenshots. - Regenerate 20-render WebGL2 matrix and 15 operation captures on RTX 4060. - Fix WASM staging for non-ASCII import filenames (猴头.glb); rebuild web_engine. - Harden lesson video recording pipeline; fix stale lesson-link E2E selector. - Add RE PBR derived libraries, lesson page, evidence and status report. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ async function openShading(page: import("@playwright/test").Page) {
|
||||
if (await webTools.count()) await webTools.click();
|
||||
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
||||
await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator(".pbr-asset-card")).toHaveCount(14, { timeout: 60_000 });
|
||||
await expect(page.locator(".pbr-asset-card")).toHaveCount(20, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
test("PBR asset library seeds OPFS, appends a model, assigns a material and renders", async ({ page }) => {
|
||||
@@ -29,7 +29,7 @@ test("PBR asset library seeds OPFS, appends a model, assigns a material and rend
|
||||
await openShading(page);
|
||||
|
||||
await expect.poll(async () => page.locator(".pbr-asset-thumb img").evaluateAll((images) =>
|
||||
images.filter((image) => (image as HTMLImageElement).naturalWidth > 0).length), { timeout: 30_000 }).toBe(14);
|
||||
images.filter((image) => (image as HTMLImageElement).naturalWidth > 0).length), { timeout: 30_000 }).toBe(20);
|
||||
|
||||
const before = await objectCount(page);
|
||||
await page.locator(".pbr-asset-card", { hasText: "PBR Cube" }).first().dblclick();
|
||||
@@ -71,7 +71,7 @@ test("seeded PBR asset catalog and previews survive a page reload", async ({ pag
|
||||
client.terminate();
|
||||
}
|
||||
});
|
||||
expect(persisted.assets).toBe(14);
|
||||
expect(persisted.assets).toBe(20);
|
||||
expect(persisted.seedId).toBe("pbr-essentials-v1");
|
||||
expect(persisted.previewBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
32
web/tests/e2e/pbr-tutorial-accessibility.spec.ts
Normal file
32
web/tests/e2e/pbr-tutorial-accessibility.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
||||
const severity = (violations: Awaited<ReturnType<AxeBuilder["analyze"]>>["violations"]) =>
|
||||
violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious").map((violation) => violation.id);
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test("PBR tutorial landing and overlay are keyboard and screen-reader accessible", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/?ui=web&tutorial=pbr");
|
||||
const landing = page.locator('main[data-course="pbr"]');
|
||||
await expect(landing).toBeVisible({ timeout: 60_000 });
|
||||
await expect(landing.getByRole("heading", { level: 1 })).toContainText("PBR");
|
||||
expect(severity((await new AxeBuilder({ page }).withTags(TAGS).analyze()).violations)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: /开始学习|继续学习/ }).click();
|
||||
const overlay = page.locator('.parts-tutorial-overlay[data-course="pbr"]');
|
||||
await expect(overlay).toBeVisible({ timeout: 120_000 });
|
||||
await expect(overlay).toHaveAttribute("data-tutorial-step", "intro");
|
||||
await expect(page.getByRole("button", { name: /^下一步/ })).toBeEnabled({ timeout: 180_000 });
|
||||
await expect(page.getByRole("progressbar", { name: "教程完成进度" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /^下一步/ }).click();
|
||||
await expect(overlay).toHaveAttribute("data-tutorial-step", "workspace-shading");
|
||||
await expect(overlay.locator(".tutorial-checks")).toHaveAttribute("aria-live", "polite");
|
||||
expect(severity((await new AxeBuilder({ page }).include(".parts-tutorial-overlay").withTags(TAGS).analyze()).violations)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: "查看课程目录" }).click();
|
||||
await expect(page.getByRole("navigation", { name: "课程目录" })).toBeVisible();
|
||||
});
|
||||
312
web/tests/e2e/re-pbr-render-workflow.spec.ts
Normal file
312
web/tests/e2e/re-pbr-render-workflow.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
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 MONKEY = path.resolve(HERE, "../../../tests/files/web/pbr-render-lesson/monkey-head.glb");
|
||||
const RE_MATERIALS = [
|
||||
"Black_Anti_Slip_grip",
|
||||
"Blackish_Brick_Wall_016",
|
||||
"black_randome_tile",
|
||||
"black_shinning_tile",
|
||||
"black_tiles_wall",
|
||||
"black_worn_wall_brick",
|
||||
];
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 1000 } });
|
||||
|
||||
const objectCount = async (page: import("@playwright/test").Page): Promise<number> => {
|
||||
const text = await page.getByTestId("scene-stats").innerText();
|
||||
return Number(/Objects (\d+)/.exec(text)?.[1] ?? "0");
|
||||
};
|
||||
|
||||
const resultMean = async (page: import("@playwright/test").Page): Promise<number> => page.evaluate(() => {
|
||||
const image = document.querySelector(".pbr-render-result img");
|
||||
if (!(image instanceof HTMLImageElement) || !image.naturalWidth) return 0;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return 0;
|
||||
context.drawImage(image, 0, 0);
|
||||
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let sum = 0;
|
||||
for (let index = 0; index < data.length; index += 4) sum += (data[index] + data[index + 1] + data[index + 2]) / 3;
|
||||
return sum / (data.length / 4);
|
||||
});
|
||||
|
||||
async function openStudio(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.goto("/?ui=web");
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
|
||||
await expect(page.locator(".blender-app")).toHaveAttribute("data-project-id", /.+/, { timeout: 90_000 });
|
||||
await expect(page.getByText(/Engine: SceneIR/).first()).toBeVisible({ timeout: 90_000 });
|
||||
await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(6);
|
||||
const webTools = page.locator('[data-testid="native-web-tools-toggle"]');
|
||||
if (await webTools.count()) await webTools.click();
|
||||
}
|
||||
|
||||
async function selectSuzanne(page: import("@playwright/test").Page): Promise<void> {
|
||||
const expand = page.locator('.outliner-tree .tree-expand[aria-label^="展开"]').first();
|
||||
if (await expand.count()) await expand.click();
|
||||
const row = page.getByRole("treeitem").filter({ hasText: /Suzanne/ }).first();
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
test("RE 5000PBR: import monkey GLB, assign six materials, render and save", async ({ page }, testInfo) => {
|
||||
test.setTimeout(600_000);
|
||||
await openStudio(page);
|
||||
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(MONKEY);
|
||||
await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7);
|
||||
await expect(page.getByText(/Engine: GLB imported/).first()).toBeVisible({ timeout: 60_000 }).catch(() => undefined);
|
||||
|
||||
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
||||
await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.locator(".pbr-asset-card")).toHaveCount(20, { timeout: 90_000 });
|
||||
await expect(page.locator('[data-library="library:re-pbr-v1"]')).toHaveCount(6, { timeout: 90_000 });
|
||||
await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"] .pbr-asset-thumb img').evaluateAll((images) =>
|
||||
images.filter((image) => (image as HTMLImageElement).naturalWidth > 0).length), { timeout: 60_000 }).toBe(6);
|
||||
|
||||
for (const name of RE_MATERIALS) {
|
||||
const card = page.locator('[data-library="library:re-pbr-v1"]', { hasText: name }).first();
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
await card.dblclick();
|
||||
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 180_000 })
|
||||
.toBe(`material:${name}`);
|
||||
await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "true", { timeout: 30_000 }).catch(() => undefined);
|
||||
await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "false", { timeout: 180_000 });
|
||||
}
|
||||
await expect.poll(async () => page.locator(".material-preview-status").innerText({ timeout: 60_000 }), { timeout: 90_000 })
|
||||
.toContain("COMPILED");
|
||||
|
||||
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
||||
const lessonPanel = page.locator(".pbr-lesson-panel");
|
||||
await expect(lessonPanel).toBeVisible({ timeout: 60_000 });
|
||||
await expect(lessonPanel).toContainText("20 个资产");
|
||||
await expect(lessonPanel).toContainText("6 个材质");
|
||||
await expect(lessonPanel.getByRole("link", { name: /打开图文/ }))
|
||||
.toHaveAttribute("href", "/tutorial/pbr/render-lesson.html");
|
||||
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
|
||||
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 120_000 }).toBe(1);
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").first().evaluate((image) =>
|
||||
(image as HTMLImageElement).naturalWidth), { timeout: 60_000 }).toBe(1024);
|
||||
const panel = page.locator(".pbr-render-panel");
|
||||
await expect.poll(async () => panel.getAttribute("data-texture-status"), { timeout: 60_000 }).toMatch(/^ready:/);
|
||||
await expect.poll(async () => Number(await panel.getAttribute("data-texture-assets") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
const sourceTriangles = Number(await panel.getAttribute("data-displacement-source-triangles") ?? "0");
|
||||
const renderTriangles = Number(await panel.getAttribute("data-displacement-render-triangles") ?? "0");
|
||||
expect(sourceTriangles).toBeGreaterThan(0);
|
||||
expect(renderTriangles).toBeGreaterThan(sourceTriangles);
|
||||
expect(Number(await panel.getAttribute("data-displacement-level") ?? "0")).toBeGreaterThan(0);
|
||||
expect(Number(await panel.getAttribute("data-displacement-multiplier") ?? "0")).toBe(1);
|
||||
const mean1x = await resultMean(page);
|
||||
|
||||
const png = page.waitForEvent("download");
|
||||
await page.locator(".pbr-render-download").click();
|
||||
const pngDownload = await png;
|
||||
expect(pngDownload.suggestedFilename()).toMatch(/\.png$/);
|
||||
const pngPath = testInfo.outputPath("re-monkey-1024.png");
|
||||
await pngDownload.saveAs(pngPath);
|
||||
expect(fs.statSync(pngPath).size).toBeGreaterThan(1024);
|
||||
|
||||
const blend = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目", exact: true }).click();
|
||||
const blendDownload = await blend;
|
||||
const blendPath = testInfo.outputPath("re-monkey-saved.blend");
|
||||
await blendDownload.saveAs(blendPath);
|
||||
expect(fs.statSync(blendPath).size).toBeGreaterThan(12);
|
||||
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(blendPath);
|
||||
await expect(page.locator(".blender-app")).toHaveAttribute("data-project-id", /.+/, { timeout: 90_000 });
|
||||
await expect(page.getByText(/Engine: SceneIR/).first()).toBeVisible({ timeout: 90_000 });
|
||||
await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7);
|
||||
const options = await page.locator('select[aria-label^="材质槽"]').first().locator("option").allTextContents();
|
||||
expect(options.join(" ")).toContain(RE_MATERIALS[RE_MATERIALS.length - 1]);
|
||||
|
||||
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
||||
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
|
||||
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 120_000 }).toBe(1);
|
||||
const reopenPanel = page.locator(".pbr-render-panel");
|
||||
await expect.poll(async () => reopenPanel.getAttribute("data-texture-status"), { timeout: 120_000 })
|
||||
.toMatch(/^ready:([1-9]\d*):0:/);
|
||||
const [expectedText, statusText] = await Promise.all([
|
||||
reopenPanel.getAttribute("data-texture-expected"),
|
||||
reopenPanel.getAttribute("data-texture-status"),
|
||||
]);
|
||||
expect(Number(statusText?.split(":")[1] ?? "0")).toBe(Number(expectedText ?? "0"));
|
||||
expect(Number(await reopenPanel.getAttribute("data-texture-revision"))).toBeGreaterThan(0);
|
||||
|
||||
const scenePosition = await reopenPanel.getAttribute("data-render-camera-position");
|
||||
expect(scenePosition).toBeTruthy();
|
||||
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("COMPARISON");
|
||||
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("VIEWPORT");
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => reopenPanel.getAttribute("data-render-camera-position"), { timeout: 120_000 })
|
||||
.not.toBe(scenePosition);
|
||||
await expect(reopenPanel).toHaveAttribute("data-view-preset", "COMPARISON");
|
||||
expect(await reopenPanel.getAttribute("data-render-camera-position")).not.toBe(scenePosition);
|
||||
|
||||
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("100");
|
||||
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
||||
const before100 = await page.locator(".pbr-render-result img").getAttribute("src");
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 })
|
||||
.not.toBe(before100);
|
||||
await expect(reopenPanel).toHaveAttribute("data-displacement-multiplier", "100");
|
||||
const mean100x = await resultMean(page);
|
||||
expect(Math.abs(mean100x - mean1x)).toBeGreaterThan(0.01);
|
||||
const displacementDownload = page.waitForEvent("download");
|
||||
await page.locator(".pbr-render-download").click();
|
||||
const displacementPng = testInfo.outputPath("re-monkey-displacement-100x.png");
|
||||
await (await displacementDownload).saveAs(displacementPng);
|
||||
expect(fs.statSync(displacementPng).size).toBeGreaterThan(1024);
|
||||
|
||||
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
|
||||
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("512");
|
||||
for (const [mode, name] of [["BASE_COLOR", "base-color"], ["NORMAL", "normal"], ["ROUGHNESS", "roughness"]] as const) {
|
||||
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption(mode);
|
||||
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
|
||||
const previous = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 })
|
||||
.not.toBe(previous);
|
||||
await expect(reopenPanel).toHaveAttribute("data-display-mode", mode);
|
||||
expect(Number(await reopenPanel.getAttribute("data-diagnostic-meshes") ?? "0")).toBeGreaterThan(0);
|
||||
const diagnosticDownload = page.waitForEvent("download");
|
||||
await page.locator(".pbr-render-download").click();
|
||||
const diagnosticPath = testInfo.outputPath(`diagnostic-${name}-512.png`);
|
||||
await (await diagnosticDownload).saveAs(diagnosticPath);
|
||||
expect(fs.statSync(diagnosticPath).size).toBeGreaterThan(1024);
|
||||
}
|
||||
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
|
||||
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
|
||||
const exposurePrevious = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 }).not.toBe(exposurePrevious);
|
||||
const exposure0 = await resultMean(page);
|
||||
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("2");
|
||||
const exposureNext = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 }).not.toBe(exposureNext);
|
||||
const exposure2 = await resultMean(page);
|
||||
expect(Math.abs(exposure2 - exposure0)).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("RE 5000PBR: truncated GLB is rejected without scene mutation", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await openStudio(page);
|
||||
const before = await objectCount(page);
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles({
|
||||
name: "truncated.glb",
|
||||
mimeType: "model/gltf-binary",
|
||||
buffer: Buffer.from("glTF\x02\x00\x00\x00\x10\x00\x00\x00", "binary"),
|
||||
});
|
||||
await expect.poll(() => objectCount(page), { timeout: 60_000 }).toBe(before);
|
||||
await expect(page.getByText(/GLB|glTF|导入失败|import/i).first()).toBeVisible({ timeout: 60_000 }).catch(() => undefined);
|
||||
});
|
||||
|
||||
test("RE 5000PBR native reader exposes HEIGHT displacement binding", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
try {
|
||||
await client.init();
|
||||
const buffer = await (await fetch("/assets/re-pbr/re-pbr-library.blend")).arrayBuffer();
|
||||
const opened = await client.openBlend(buffer);
|
||||
const material = opened.snapshot.materials.find((candidate) => candidate.name === "Black_Anti_Slip_grip");
|
||||
return {
|
||||
binding: material?.textureBindings?.find((candidate) => candidate.usage === "HEIGHT") ?? null,
|
||||
displacementImageId: material?.displacementImageId ?? null,
|
||||
displacementScale: material?.displacementScale ?? null,
|
||||
displacementMidlevel: material?.displacementMidlevel ?? null,
|
||||
textureBindings: material?.textureBindings ?? null,
|
||||
nodeTypes: material?.nodes?.map((node) => node.type) ?? null,
|
||||
links: material?.links ?? null,
|
||||
materials: opened.snapshot.materials.map((candidate) => candidate.name),
|
||||
};
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
}
|
||||
});
|
||||
expect(result.binding).toMatchObject({ usage: "HEIGHT", midlevel: 0.5 });
|
||||
expect(result.binding.scale).toBeCloseTo(0.001, 6);
|
||||
expect(result.displacementImageId).toContain("Height");
|
||||
expect(result.displacementScale).toBeCloseTo(0.001, 6);
|
||||
expect(result.displacementMidlevel).toBeCloseTo(0.5, 6);
|
||||
|
||||
});
|
||||
|
||||
test("RE 5000PBR: optional HQ 2048 library loads on demand and renders", async ({ page }, testInfo) => {
|
||||
test.setTimeout(600_000);
|
||||
await openStudio(page);
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(MONKEY);
|
||||
await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7);
|
||||
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
||||
const hqButton = page.getByRole("button", { name: /加载高清贴图|高清贴图已加载/ });
|
||||
await expect(hqButton).toBeVisible({ timeout: 60_000 });
|
||||
if (await hqButton.getAttribute("disabled") === null) {
|
||||
await hqButton.click();
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "高清贴图已加载" })).toBeVisible({ timeout: 180_000 });
|
||||
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
||||
await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 60_000 });
|
||||
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-hq-v1");
|
||||
await expect(page.locator('[data-library="library:re-pbr-hq-v1"]')).toHaveCount(6, { timeout: 60_000 });
|
||||
const card = page.locator('[data-library="library:re-pbr-hq-v1"]', { hasText: "black_worn_wall_brick_HQ" }).first();
|
||||
await card.dblclick();
|
||||
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 180_000 })
|
||||
.toBe("material:black_worn_wall_brick_HQ");
|
||||
await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "false", { timeout: 180_000 });
|
||||
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
||||
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
|
||||
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 180_000 }).toBe(1);
|
||||
const panel = page.locator(".pbr-render-panel");
|
||||
await expect.poll(async () => panel.getAttribute("data-texture-status"), { timeout: 180_000 }).toMatch(/^ready:([1-9]\d*):0:/);
|
||||
expect(Number(await panel.getAttribute("data-texture-budget-gpu-bytes") ?? "0")).toBeGreaterThan(0);
|
||||
expect(Number(await panel.getAttribute("data-texture-budget-gpu-bytes") ?? "0")).toBeLessThan(512 * 1024 * 1024);
|
||||
const download = page.waitForEvent("download");
|
||||
await page.locator(".pbr-render-download").click();
|
||||
const png = testInfo.outputPath("re-pbr-hq-2048.png");
|
||||
await (await download).saveAs(png);
|
||||
expect(fs.statSync(png).size).toBeGreaterThan(1024);
|
||||
});
|
||||
|
||||
|
||||
test("RE 5000PBR HQ native images keep 2048 dimensions", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const images = await page.evaluate(async () => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
try {
|
||||
await client.init();
|
||||
const buffer = await (await fetch("/assets/re-pbr-hq/re-pbr-library.blend")).arrayBuffer();
|
||||
const opened = await client.openBlend(buffer);
|
||||
return opened.snapshot.images.map((image) => ({
|
||||
name: image.name,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
tiles: image.tiles?.map((tile) => ({ width: tile.width, height: tile.height })) ?? null,
|
||||
}));
|
||||
}
|
||||
finally { client.terminate(); }
|
||||
});
|
||||
const heightImages = images.filter((image) => /Height\.jpg$/.test(image.name));
|
||||
expect(heightImages.length).toBeGreaterThanOrEqual(1);
|
||||
for (const image of heightImages) {
|
||||
expect(image.width).toBe(2048);
|
||||
expect(image.height).toBe(2048);
|
||||
}
|
||||
});
|
||||
48
web/tests/e2e/wbv2-pbr-tutorial-production.spec.ts
Normal file
48
web/tests/e2e/wbv2-pbr-tutorial-production.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 1000 } });
|
||||
|
||||
test("PBR tutorial runs every demo through the real asset library and renderer", async ({ page }) => {
|
||||
test.setTimeout(420_000);
|
||||
const downloads: string[] = [];
|
||||
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
||||
|
||||
await page.goto("/?ui=web&tutorial=pbr");
|
||||
await page.getByRole("button", { name: /开始学习|继续学习/ }).click();
|
||||
const overlay = page.locator('.parts-tutorial-overlay[data-course="pbr"]');
|
||||
await expect(overlay).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.locator('.tutorial-loading[data-tutorial-ready="false"]')).toHaveCount(0, { timeout: 180_000 });
|
||||
|
||||
const seen: string[] = [];
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const step = await overlay.getAttribute("data-tutorial-step");
|
||||
if (!step) break;
|
||||
seen.push(step);
|
||||
const demo = page.getByRole("button", { name: "演示本步" });
|
||||
if (await demo.count()) {
|
||||
await demo.click({ timeout: 60_000 });
|
||||
await expect.poll(async () => page.locator("[data-check-id]").evaluateAll((rows) =>
|
||||
rows.length > 0 && rows.every((row) => row.getAttribute("data-ok") === "true")), { timeout: 240_000 }).toBe(true);
|
||||
}
|
||||
if (step === "workspace-shading") {
|
||||
await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.locator(".pbr-asset-card")).toHaveCount(20, { timeout: 60_000 });
|
||||
}
|
||||
if (step === "render-setup") {
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").first().evaluate((image) =>
|
||||
(image as HTMLImageElement).naturalWidth), { timeout: 120_000 }).toBeGreaterThan(0);
|
||||
}
|
||||
const next = page.getByRole("button", { name: /下一步|下一节/ });
|
||||
if (!(await next.count())) break;
|
||||
await next.click({ timeout: 120_000 });
|
||||
}
|
||||
|
||||
expect(seen).toEqual(expect.arrayContaining([
|
||||
"workspace-shading", "load-studio", "add-suzanne", "add-cube", "add-torus",
|
||||
"assign-metal", "assign-glass", "assign-wood", "assign-emission",
|
||||
"render-setup", "download-save", "summary",
|
||||
]));
|
||||
expect(downloads.some((name) => /\.png$/i.test(name))).toBe(true);
|
||||
expect(downloads.some((name) => /\.blend$/i.test(name))).toBe(true);
|
||||
await expect(overlay).toContainText("12 张材质对比");
|
||||
});
|
||||
@@ -268,3 +268,68 @@ test("M10-07 keeps texture-fed Mix blocked with a visible issue", () => {
|
||||
assert.equal(report.status, "BLOCKED");
|
||||
assert.ok(report.issues.some((entry) => entry.message.includes("Mix color inputs")));
|
||||
});
|
||||
|
||||
test("RE 5000PBR graph collapses REROUTE, ignores FRAME, keeps DISPLACEMENT/HEIGHT and applies Value-driven Mapping", () => {
|
||||
const report = compiler.compileMaterialGraph(material({
|
||||
nodes: [
|
||||
{ id: "uv", type: "TEX_COORD", name: "UV" },
|
||||
{ id: "mapping", type: "MAPPING", name: "Mapping" },
|
||||
{ id: "reroute", type: "REROUTE", name: "Reroute" },
|
||||
{ id: "frame", type: "FRAME", name: "Frame" },
|
||||
{ id: "value", type: "VALUE", name: "Value", defaultValue: [2] },
|
||||
{ id: "base", type: "IMAGE_TEXTURE", name: "Base", imageId: "image:Base" },
|
||||
{ id: "rough", type: "IMAGE_TEXTURE", name: "Rough", imageId: "image:Rough" },
|
||||
{ id: "height", type: "IMAGE_TEXTURE", name: "Height", imageId: "image:Height" },
|
||||
{ id: "displacement", type: "DISPLACEMENT", name: "Displacement" },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "uv", fromSocket: "UV", toNodeId: "mapping", toSocket: "Vector" },
|
||||
{ fromNodeId: "value", fromSocket: "Value", toNodeId: "mapping", toSocket: "Scale" },
|
||||
{ fromNodeId: "mapping", fromSocket: "Vector", toNodeId: "reroute", toSocket: "Input" },
|
||||
{ fromNodeId: "reroute", fromSocket: "Output", toNodeId: "base", toSocket: "Vector" },
|
||||
{ fromNodeId: "reroute", fromSocket: "Output", toNodeId: "rough", toSocket: "Vector" },
|
||||
{ fromNodeId: "reroute", fromSocket: "Output", toNodeId: "height", toSocket: "Vector" },
|
||||
{ fromNodeId: "base", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" },
|
||||
{ fromNodeId: "rough", fromSocket: "Color", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "height", fromSocket: "Color", toNodeId: "displacement", toSocket: "Height" },
|
||||
{ fromNodeId: "displacement", fromSocket: "Displacement", toNodeId: "output", toSocket: "Displacement" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
}), { imageIds: new Set(["image:Base", "image:Rough", "image:Height"]) });
|
||||
assert.equal(report.status, "COMPILED");
|
||||
assert.ok(report.compiledNodeTypes.every((type) => !["REROUTE", "FRAME"].includes(type)));
|
||||
assert.ok(report.compiledNodeTypes.includes("DISPLACEMENT"));
|
||||
const base = report.textureBindings.find((binding) => binding.usage === "BASE_COLOR");
|
||||
assert.deepEqual(base.uvTransform?.scale, [2, 2]);
|
||||
assert.ok(report.textureBindings.some((binding) => binding.usage === "ROUGHNESS"));
|
||||
const height = report.textureBindings.find((binding) => binding.usage === "HEIGHT");
|
||||
assert.equal(height.imageId, "image:Height");
|
||||
assert.equal(height.scale, 0.01);
|
||||
assert.equal(height.midlevel, 0.5);
|
||||
assert.deepEqual(height.uvTransform?.scale, [2, 2]);
|
||||
assert.equal(report.material.displacementImageId, "image:Height");
|
||||
assert.equal(report.material.displacementScale, 0.01);
|
||||
assert.equal(report.material.displacementMidlevel, 0.5);
|
||||
assert.equal(report.material.roughness, 1);
|
||||
});
|
||||
|
||||
test("RE 5000PBR roughness/metallic can be read through Separate Color", () => {
|
||||
const report = compiler.compileMaterialGraph(material({
|
||||
nodes: [
|
||||
{ id: "image", type: "IMAGE_TEXTURE", name: "Mask", imageId: "image:Mask" },
|
||||
{ id: "separate", type: "SEPARATE_COLOR", name: "Separate" },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "separate", toSocket: "Color" },
|
||||
{ fromNodeId: "separate", fromSocket: "Blue", toNodeId: "principled", toSocket: "Metallic" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
}), { imageIds: new Set(["image:Mask"]) });
|
||||
assert.equal(report.status, "COMPILED");
|
||||
assert.ok(report.textureBindings.some((binding) => binding.usage === "METALLIC" && binding.imageId === "image:Mask"));
|
||||
assert.equal(report.material.metallic, 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user