feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

- 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:
mes123456
2026-11-12 21:49:24 +08:00
parent a078648297
commit d5dd647881
276 changed files with 9632 additions and 314 deletions

View File

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

View 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();
});

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

View 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 张材质对比");
});