feat(manual): add the illustrated Web Blender HTML user manual
A single-page 28-chapter manual generated from manual.json covers the 11 workspaces, all React editors, OPFS save/reopen, explicit local downloads, import/export routes, preferences/keymap and the capability matrix. It reuses verified course screenshots and adds 14 fresh Chromium captures for shell, storage, area, camera, preferences, IO and troubleshooting chapters. Manual-driven fixes: Save As now states OPFS-only, GLTF import is restored, quick toolbar honors its preference, startup workspace is applied, Properties tabs jump to sections, Outliner fallback search filters, and the editor-header ⋮ opens the area menu. Help and the tutorial landing link the new manual; the PDF remains as an archive. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
45
web/tests/e2e/user-manual-smoke.spec.ts
Normal file
45
web/tests/e2e/user-manual-smoke.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
|
||||
test("HTML manual has chapters, figures, working search and no overflow", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.goto("/manuals/index.html", { waitUntil: "load" });
|
||||
await expect(page.locator(".manual-chapter")).toHaveCount(28);
|
||||
await expect(page.locator(".manual-nav a[data-nav-id]")).toHaveCount(32);
|
||||
await expect(page.getByLabel("搜索手册", { exact: true })).toBeVisible();
|
||||
await expect(page.locator("figure img").first()).toBeVisible();
|
||||
const firstImage = await page.locator("figure img").first().evaluate((element) => ({ complete: (element as HTMLImageElement).complete, width: (element as HTMLImageElement).naturalWidth }));
|
||||
expect(firstImage.complete).toBe(true);
|
||||
expect(firstImage.width).toBeGreaterThan(0);
|
||||
|
||||
await page.getByLabel("搜索手册", { exact: true }).fill("相机");
|
||||
await expect(page.locator(".manual-search-status")).toContainText("匹配");
|
||||
await expect.poll(async () => page.locator(".manual-nav a[data-nav-id][hidden]").count()).toBeGreaterThan(0);
|
||||
await page.getByLabel("搜索手册", { exact: true }).fill("");
|
||||
await expect(page.locator(".manual-search-status")).toHaveText("");
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("manual is responsive and reachable from Help", async ({ page, context }) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/manuals/index.html", { waitUntil: "load" });
|
||||
await expect(page.locator(".manual-menu-toggle")).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await page.goto("/?ui=web", { waitUntil: "domcontentloaded" });
|
||||
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||
await page.getByRole("button", { name: "帮助", exact: true }).click();
|
||||
const entry = page.getByRole("menuitem", { name: "使用手册(网页版)", exact: true });
|
||||
await expect(entry).toBeVisible();
|
||||
const popupPromise = context.waitForEvent("page", { timeout: 30_000 });
|
||||
await entry.click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState("domcontentloaded");
|
||||
expect(new URL(popup.url()).pathname).toBe("/manuals/index.html");
|
||||
await expect(popup.locator(".manual-chapter")).toHaveCount(28);
|
||||
});
|
||||
@@ -16,7 +16,7 @@ async function openStudio(page: import("@playwright/test").Page): Promise<void>
|
||||
}
|
||||
|
||||
async function openAreaMenu(page: import("@playwright/test").Page, area: import("@playwright/test").Locator): Promise<void> {
|
||||
await area.locator(".area-menu button").evaluate((element) => (element as HTMLElement).click());
|
||||
await area.locator(".area-type-switch .area-menu button").evaluate((element) => (element as HTMLElement).click());
|
||||
await page.getByRole("menu").last().waitFor({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -77,4 +77,10 @@ test("Camera to View writes the camera and Camera to View lock moves it", async
|
||||
|
||||
const otherCanvas = page.locator(".camera-view-area canvas.viewport-canvas").first();
|
||||
if (await otherCanvas.count()) await expect(otherCanvas).not.toHaveAttribute("data-camera-locked", "true");
|
||||
|
||||
await page.reload();
|
||||
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||
const reloaded = page.locator(".viewport-area canvas.viewport-canvas").first();
|
||||
await reloaded.waitFor({ timeout: 60_000 });
|
||||
await expect.poll(async () => reloaded.getAttribute("data-camera-locked"), { timeout: 30_000 }).toBe("true");
|
||||
});
|
||||
|
||||
49
web/tests/e2e/web-manual-ui-gaps.spec.ts
Normal file
49
web/tests/e2e/web-manual-ui-gaps.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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 expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
|
||||
test("Properties tabs jump to object, modifier and material sections", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await openStudio(page);
|
||||
const properties = page.locator(".properties-area");
|
||||
const modifierTab = properties.locator('button[title="修改器属性"]');
|
||||
await modifierTab.evaluate((element) => (element as HTMLElement).click());
|
||||
await expect(modifierTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect.poll(async () => properties.locator(".modifier-panel").evaluate((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
const area = element.closest(".properties-area")!.getBoundingClientRect();
|
||||
return box.top >= area.top - 2 && box.top < area.bottom;
|
||||
})).toBe(true);
|
||||
const materialTab = properties.locator('button[title="材质属性"]');
|
||||
await materialTab.evaluate((element) => (element as HTMLElement).click());
|
||||
await expect(properties.getByTestId("material-slots-section")).toBeVisible();
|
||||
await expect(materialTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("Editor header ⋮ opens the same area menu as the floating switcher", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await openStudio(page);
|
||||
await page.locator(".workspace-tabs button", { hasText: "Scripting" }).evaluate((element) => (element as HTMLElement).click());
|
||||
await page.waitForTimeout(600);
|
||||
const headerMenu = page.locator(".viewport-area .editor-header .area-menu button").first();
|
||||
await expect(headerMenu).toBeVisible({ timeout: 30_000 });
|
||||
await headerMenu.evaluate((element) => (element as HTMLElement).click());
|
||||
await page.getByRole("menuitem", { name: "垂直分割(左右)", exact: true }).click();
|
||||
await expect(page.locator("main.blender-app")).toHaveAttribute("data-area-layout", "custom");
|
||||
await page.locator(".workspace-grid > .area-frame .area-type-switch .area-menu button").first().evaluate((element) => (element as HTMLElement).click());
|
||||
await page.getByRole("menuitem", { name: "重置工作区布局", exact: true }).click();
|
||||
await expect(page.locator("main.blender-app")).toHaveAttribute("data-area-layout", "default");
|
||||
});
|
||||
@@ -63,3 +63,20 @@ test("Keymap conflict is rejected and unsupported preference rows are marked", a
|
||||
await page.getByRole("button", { name: "视口", exact: true }).click();
|
||||
await expect(page.locator('.preference-row[data-setting="viewport.gizmoSize"]')).toHaveAttribute("data-unsupported", "true");
|
||||
});
|
||||
|
||||
test("Quick toolbar and startup workspace preferences take effect", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.goto("/?ui=web");
|
||||
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||
await expect(page.locator(".legacy-quick-toolbar")).toBeVisible();
|
||||
await openPreferences(page);
|
||||
await page.getByLabel("显示 Web 快捷操作栏", { exact: true }).uncheck();
|
||||
await page.getByLabel("启动工作区", { exact: true }).selectOption("Animation");
|
||||
await page.getByRole("button", { name: "保存偏好", exact: true }).click();
|
||||
await page.getByRole("button", { name: "关闭偏好设置", exact: true }).click();
|
||||
await expect(page.locator(".legacy-quick-toolbar")).toBeHidden();
|
||||
await page.reload();
|
||||
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||
await expect.poll(async () => page.locator("main.blender-app").getAttribute("data-workspace"), { timeout: 60_000 }).toBe("Animation");
|
||||
await expect(page.locator(".legacy-quick-toolbar")).toBeHidden();
|
||||
});
|
||||
|
||||
28
web/tests/e2e/web-save-download-semantics.spec.ts
Normal file
28
web/tests/e2e/web-save-download-semantics.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
async function openSaveAs(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "文件", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "另存为", exact: true }).click();
|
||||
await page.getByLabel("另存为文件名", { exact: true }).waitFor({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test("Save As writes OPFS only and local .blend requires the explicit download action", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.goto("/?ui=web");
|
||||
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||
await expect(page.getByTestId("scene-stats")).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
await openSaveAs(page);
|
||||
await expect(page.getByText("不会自动下载文件", { exact: false })).toBeVisible();
|
||||
await page.getByLabel("另存为文件名", { exact: true }).fill("manual-save-as.blend");
|
||||
const unexpectedDownload = page.waitForEvent("download", { timeout: 2_000 }).then(() => true).catch(() => false);
|
||||
await page.getByRole("button", { name: "确认", exact: true }).click();
|
||||
expect(await unexpectedDownload).toBe(false);
|
||||
await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED", { timeout: 60_000 });
|
||||
|
||||
const download = page.waitForEvent("download", { timeout: 60_000 });
|
||||
await page.locator('button[aria-label="下载 .blend"]').evaluate((element) => (element as HTMLElement).click());
|
||||
expect((await download).suggestedFilename()).toMatch(/\.blend$/);
|
||||
});
|
||||
Reference in New Issue
Block a user