Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
83 lines
6.0 KiB
TypeScript
83 lines
6.0 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import { NativeUIWorkflow, exposeWebTools, observeNative } from "../fixtures/native-ui-workflow";
|
|
|
|
const root = path.resolve(import.meta.dirname, "../../..");
|
|
const fixture = path.join(root, "tests/files/web/basic_scene.blend");
|
|
const fixtureSha256 = crypto.createHash("sha256").update(fs.readFileSync(fixture)).digest("hex");
|
|
|
|
/* One browser holds /browser exclusively, so the case drives the app's own engine
|
|
* worker on the real file-open entry instead of opening a second writer. */
|
|
const call = (page: Page, command: unknown) => page.evaluate(command => new Promise<any>((resolve, reject) => {
|
|
const worker = (window as unknown as { __nativeWorkflowEngine: Worker }).__nativeWorkflowEngine;
|
|
const requestId = `sculpt-${crypto.randomUUID()}`;
|
|
const receive = (event: MessageEvent) => {
|
|
if (event.data?.requestId !== requestId || event.data?.kind !== "result") return;
|
|
worker.removeEventListener("message", receive);
|
|
if (event.data.ok) resolve(event.data.result); else reject(event.data.error);
|
|
};
|
|
worker.addEventListener("message", receive);
|
|
worker.postMessage({ requestId, command });
|
|
}), command);
|
|
|
|
test("WBV2-P1121-000 preserves bounded Sculpt stroke Main mutation", async ({ page }, testInfo) => {
|
|
test.setTimeout(180_000);
|
|
page.setDefaultTimeout(15_000);
|
|
const receipts = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("cases");
|
|
fs.mkdirSync(receipts, { recursive: true });
|
|
const flow = new NativeUIWorkflow(page, receipts);
|
|
const load = (file: string) => flow.command(() => page.getByTestId("blend-file-input").setInputFiles(file));
|
|
await observeNative(page);
|
|
await page.goto("/");
|
|
await load(fixture);
|
|
const geometry = (snapshot: any, meshId: string) => {
|
|
const mesh = snapshot.meshes?.find((entry: any) => entry.id === meshId);
|
|
if (!mesh) throw new Error(`mesh missing: ${meshId}`);
|
|
return { revision: snapshot.revision, bounds: mesh.bounds };
|
|
};
|
|
let result: any = { error: { fatal: true } };
|
|
try {
|
|
const opened = await call(page, { type: "snapshot" });
|
|
const mesh = opened.snapshot.meshes?.find((entry: any) => entry.id === opened.snapshot.nodes.find((node: any) => node.type === "MESH")?.dataId);
|
|
const object = opened.snapshot.nodes?.find((node: any) => node.type === "MESH");
|
|
if (!mesh || !object) throw new Error("basic fixture has no editable Mesh");
|
|
const before = geometry(opened.snapshot, mesh.id);
|
|
const stroke = { schemaVersion: 1, meshId: mesh.id, brush: "DRAW", samples: [{ position: [0, 0, 0], normal: [0, 0, 1], radius: 100, strength: 0.5, pressure: 1, time: 0 }], symmetry: [false, false, false], mirrorObjectSpace: true };
|
|
const edited = await call(page, { type: "applyCommand", payload: { type: "sculptStroke", stroke } });
|
|
const afterEdit = geometry(edited.snapshot, mesh.id);
|
|
const undo = await call(page, { type: "applyCommand", payload: { type: "undo" } });
|
|
const afterUndo = geometry(undo.snapshot, mesh.id);
|
|
const redo = await call(page, { type: "applyCommand", payload: { type: "redo" } });
|
|
const afterRedo = geometry(redo.snapshot, mesh.id);
|
|
const attributePayload = { schemaVersion: 1, meshId: mesh.id, vertexCount: mesh.vertexCount, faceCount: mesh.faceCount, mask: Array(mesh.vertexCount).fill(0.25), faceSets: Array(mesh.faceCount).fill(2), activeFaceSet: -1, revision: redo.snapshot.revision };
|
|
const attributesEdit = await call(page, { type: "applyCommand", payload: { type: "setSculptMeshAttributes", attributes: attributePayload } });
|
|
const attributesMesh = attributesEdit.snapshot.meshes?.find((entry: any) => entry.id === mesh.id);
|
|
const malformed = await call(page, { type: "applyCommand", payload: { type: "sculptStroke", stroke: { ...stroke, samples: [{ ...stroke.samples[0], pressure: 2 }] } } }).then(() => ({ code: "NO_ERROR" })).catch((error: any) => ({ code: error?.code, message: error?.message }));
|
|
const afterMalformed = geometry((await call(page, { type: "snapshot" })).snapshot, mesh.id);
|
|
await exposeWebTools(page);
|
|
const download = page.waitForEvent("download", { timeout: 30_000 });
|
|
await flow.command(() => page.getByRole("button", { name: "保存项目", exact: true }).click());
|
|
const savedPath = path.join(receipts, "sculpt-after.blend");
|
|
await (await download).saveAs(savedPath);
|
|
await load(savedPath);
|
|
const afterReopen = geometry((await call(page, { type: "snapshot" })).snapshot, mesh.id);
|
|
result = { fixtureSha256, meshId: mesh.id, before, afterEdit, afterUndo, afterRedo, attributesRevision: attributesEdit.snapshot.revision, attributes: { sculptMask: attributesMesh?.sculptMask, faceSets: attributesMesh?.faceSets }, malformed, afterMalformed, afterReopen, savedSha256: crypto.createHash("sha256").update(fs.readFileSync(savedPath)).digest("hex") };
|
|
} catch (error: any) { result = { error: { code: error?.code, message: error?.message, detail: String(error) } }; }
|
|
fs.mkdirSync(path.join(root, "tests/golden/WBV2-P1121-000"), { recursive: true });
|
|
fs.writeFileSync(path.join(receipts, "sculpt-stroke-production.json"), `${JSON.stringify({ schemaVersion: 1, task: "WBV2-P1121-000", operation: "SCULPT_STROKE_MAIN_CHROMIUM", browser: "chromium", queueMutation: false, acceptance: "NOT_CLAIMED", ...result }, null, 2)}\n`);
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.fixtureSha256).toBe(fixtureSha256);
|
|
expect(result.afterEdit.revision).toBe(result.before.revision + 1);
|
|
expect(result.afterEdit.bounds.max[2]).toBeGreaterThan(result.before.bounds.max[2]);
|
|
expect(result.afterUndo.bounds).toEqual(result.before.bounds);
|
|
expect(result.afterRedo.bounds).toEqual(result.afterEdit.bounds);
|
|
expect(result.attributesRevision).toBe(result.afterRedo.revision + 1);
|
|
expect(result.malformed.code).not.toBe("NO_ERROR");
|
|
expect(result.afterMalformed.bounds).toEqual(result.afterRedo.bounds);
|
|
expect(result.afterReopen.bounds).toEqual(result.afterRedo.bounds);
|
|
expect(result.afterReopen.revision).toBe(1);
|
|
expect(result.savedSha256).not.toBe(fixtureSha256);
|
|
});
|