Files
workinf_Blender_Wasm/web/tests/e2e/web-spreadsheet-readonly.spec.ts
mes123456 4efb4b778d feat(web): add an interactive read-only Spreadsheet view
Geometry Nodes workspace now exposes real mesh buffers (objects,
vertices, edges, faces, UVs, attributes) with filtering, column
show/hide/reorder, row selection, pagination and explicit CSV export.
Interactions never touch the Main revision.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-13 05:13:33 +08:00

124 lines
7.3 KiB
TypeScript

import { expect, test } from "@playwright/test";
import fs from "node:fs/promises";
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 openSpreadsheet(page: import("@playwright/test").Page): Promise<import("@playwright/test").Locator> {
await page.locator(".workspace-tabs button", { hasText: "Geometry Nodes" }).evaluate((element) => (element as HTMLElement).click());
const sheet = page.getByTestId("spreadsheet-view");
await sheet.waitFor({ timeout: 60_000 });
return sheet;
}
test("Spreadsheet browses mesh data, filters columns and exports CSV without editing the Main", async ({ page }) => {
test.setTimeout(300_000);
await openStudioAndMonkey(page);
const sheet = await openSpreadsheet(page);
await expect(sheet).toHaveAttribute("data-source", "objects");
await expect.poll(async () => sheet.getAttribute("data-row-count"), { timeout: 30_000 }).toBe("7");
await expect.poll(async () => sheet.getByTestId("spreadsheet-row").count()).toBe(7);
await sheet.getByLabel("数据源").selectOption("vertices");
await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(100);
await expect(sheet.getByTestId("spreadsheet-table")).toBeVisible();
await expect.poll(async () => sheet.getByTestId("spreadsheet-row").count()).toBeGreaterThan(0);
await sheet.getByTestId("spreadsheet-row").first().click();
await expect(sheet).toHaveAttribute("data-selected-rows", "1");
await sheet.getByRole("button", { name: "选择本页", exact: true }).click();
const pageRows = await sheet.getByTestId("spreadsheet-row").count();
await expect.poll(async () => Number(await sheet.getAttribute("data-selected-rows"))).toBe(pageRows);
await sheet.getByLabel("过滤行").fill("no-such-value-4d7a");
await expect.poll(async () => sheet.getAttribute("data-row-count")).toBe("0");
await expect(sheet.getByText("没有匹配过滤条件的行。")).toBeVisible();
await sheet.getByLabel("过滤行").fill("");
await expect.poll(async () => Number(await sheet.getAttribute("data-row-count"))).toBeGreaterThan(pageRows);
await sheet.getByLabel("每页行数").selectOption("50");
await expect.poll(async () => sheet.getByTestId("spreadsheet-row").count()).toBe(50);
await sheet.getByRole("button", { name: "下一页", exact: true }).click();
await expect.poll(async () => sheet.getAttribute("data-page")).toBe("1");
const beforeColumns = (await sheet.getAttribute("data-visible-columns")) ?? "";
await sheet.locator("summary", { hasText: "列" }).click();
await sheet.getByLabel("显示列 index").uncheck();
await expect.poll(async () => sheet.getAttribute("data-visible-columns")).not.toContain("index");
const withoutIndex = (await sheet.getAttribute("data-visible-columns")) ?? "";
expect(withoutIndex.length).toBeLessThan(beforeColumns.length);
await sheet.getByLabel("右移列 x").click();
const reordered = (await sheet.getAttribute("data-visible-columns")) ?? "";
expect(reordered).not.toBe(withoutIndex);
expect(reordered.split(",").slice(0, 2)).toEqual(["y", "x"]);
await sheet.getByLabel("显示列 index").check();
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await sheet.getByTestId("spreadsheet-export").click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^spreadsheet-vertices-.*\.csv$/);
const downloaded = await download.path();
expect(downloaded).toBeTruthy();
const csv = await fs.readFile(downloaded!, "utf8");
const lines = csv.trim().split("\n");
expect(lines.length).toBeGreaterThan(pageRows);
expect(lines[0].split(",")).toContain("x");
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
await page.waitForTimeout(400);
const after = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
expect(after).toBe(before);
});
test("Spreadsheet reports mesh attributes and keeps working after an OPFS reopen", async ({ page }) => {
test.setTimeout(360_000);
await openStudioAndMonkey(page);
const sheet = await openSpreadsheet(page);
await sheet.getByLabel("数据源").selectOption("attributes");
await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(0);
const attributeNames = await sheet.getByTestId("spreadsheet-row").allInnerTexts();
expect(attributeNames.join("\n")).toMatch(/position/);
await sheet.getByLabel("数据源").selectOption("uv");
await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(0);
await sheet.getByLabel("数据源").selectOption("faces");
await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(0);
await sheet.getByLabel("数据源").selectOption("edges");
await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(0);
const meshSelect = sheet.getByLabel("网格");
const options = await meshSelect.locator("option").count();
expect(options).toBeGreaterThan(1);
await meshSelect.selectOption({ index: options - 1 });
await expect.poll(async () => sheet.getAttribute("data-mesh-id")).not.toBe("");
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 openSpreadsheet(page);
await expect.poll(async () => reopened.getAttribute("data-row-count"), { timeout: 60_000 }).toBe("7");
await reopened.getByLabel("数据源").selectOption("vertices");
await expect.poll(async () => Number(await reopened.getAttribute("data-row-count")), { timeout: 60_000 }).toBeGreaterThan(100);
});