import { expect, test } from "@playwright/test"; import path from "node:path"; import fs from 'node:fs'; import { captureNativeWorkflowState, exposeWebTools, observeNative, performNativeAction, resolveProjectReplacement } from '../fixtures/native-ui-workflow'; import { webMain } from '../fixtures/console-main-workflow'; import { expectUILocation, openFromUI, showNativeObjectProperties } from '../fixtures/wasm-console-workflow'; import { expectModelingData, expectVector, focusNativeViewport, nativeKeyFrame, nativeRequest, queryNativeJSON, projectNearestVertex, readModelingMain, readNativeBlend, readNativeHistory, transformNative, waitModelingMode, waitNativeModal } from '../fixtures/native-modeling-workflow'; const fixture = path.resolve(import.meta.dirname, "../../..", process.env.WBV2_CASE_FIXTURE ?? "tests/files/web/basic_scene.blend"); test("Native modeling closes transforms, vertex edit, history and reopen", async ({ page }, testInfo) => { // The full desktop modal/edit/save/reopen path takes ~60s in Chromium with // SwiftShader, so it needs the longer modal-suite budget, not the 30s default. test.setTimeout(300_000); const evidence: Record = {}, record = (stage: string, value: unknown) => { evidence[stage] = value; fs.writeFileSync(testInfo.outputPath('b2-main.json'), JSON.stringify(evidence, null, 2)); }; await observeNative(page); await page.goto('/'); await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true); await exposeWebTools(page); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(fixture)); const opened = await webMain(page); record('opened', opened); await performNativeAction(page, { command: 'createPrimitive' }, () => page.getByRole('button', { name: '添加立方体', exact: true }).click()); const added = (await webMain(page)).nodes.filter(node => !opened.nodes.some(previous => previous.id === node.id)); expect(added).toHaveLength(1); expect(added[0].type).toBe('MESH'); const name = added[0].name; const { canvas, point } = await focusNativeViewport(page); const before = await readModelingMain(page, name); expect(before.value.vertices).toHaveLength(8); record('added', before); expect(typeof before.value.toolId).toBe('string'); expect(before.value.toolId.length).toBeGreaterThan(0); expect(before.value.orientation).toBe('GLOBAL'); expect(before.value.rotationMode).toBe('XYZ'); const historyBeforeCancel = await readNativeHistory(page); record('historyBeforeCancel', historyBeforeCancel); fs.writeFileSync(testInfo.outputPath('before-cancel.blend'), await readNativeBlend(page)); record('transformPrerequisites', await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'active':bpy.context.object.name if bpy.context.object else None,'area':bpy.context.area.type if bpy.context.area else None,'keymaps':{c.name:{m.name:[(i.idname,i.type,i.value,i.active) for i in m.keymap_items if i.idname=='transform.translate'] for m in c.keymaps if m.name in {'3D View','Object Mode'}} for c in bpy.context.window_manager.keyconfigs}}))")); await canvas.focus(); record('transformKey', await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 71 }, () => page.keyboard.press('g'))); record('transformInput', { state: await captureNativeWorkflowState(page), inputs: await page.evaluate(() => (window as any).__nativeWorkflowRequests.filter((request: any) => request.inputEvents?.length).slice(-16)) }); const moving = await waitNativeModal(page, true); record('cancelStarted', moving); await page.mouse.move(point.x + 60, point.y + 35); await waitNativeModal(page, true, moving.requestId); await expect(nativeRequest(page, { type: 'jsonCommand', cmd: 'pythonExec', params: { source: "import bpy; bpy.context.scene['wbv2_guard_should_not_run'] = True", filename: 'ActiveGestureGuard.py', console: false } })).rejects.toThrow(/NATIVE_GESTURE_ACTIVE/); await page.keyboard.press('Escape'); record('cancelFinished', await waitNativeModal(page, false, moving.requestId)); expect((await queryNativeJSON(page, "import bpy,json\nprint(json.dumps('wbv2_guard_should_not_run' in bpy.context.scene))")).value).toBe(false); const cancelled = await readModelingMain(page, name); record('cancelled', cancelled); expect(cancelled.value).toEqual(before.value); fs.writeFileSync(testInfo.outputPath('after-cancel.blend'), await readNativeBlend(page)); const historyAfterCancel = await readNativeHistory(page); record('historyAfterCancel', historyAfterCancel); expect(historyAfterCancel.cursor).toBe(historyBeforeCancel.cursor); expect(historyAfterCancel.entries).toEqual(historyBeforeCancel.entries); await page.keyboard.press('g'); const translating = await waitNativeModal(page, true); await page.keyboard.press('x'); await page.keyboard.type('1'); await page.keyboard.press('Enter'); record('confirmFinished', await waitNativeModal(page, false, translating.requestId)); const confirmed = await readModelingMain(page, name); record('confirmed', confirmed); expect(confirmed.value.location[0]).toBeCloseTo(before.value.location[0] + 1, 5); expect(confirmed.value.location.slice(1)).toEqual(before.value.location.slice(1)); expect(confirmed.revision).toBeGreaterThan(before.revision); record('rotateModal', await transformNative(page, 'r', 'z', '90')); const rotated = await readModelingMain(page, name); record('rotated', rotated); expectVector(rotated.value.rotation, [before.value.rotation[0], before.value.rotation[1], before.value.rotation[2] + Math.PI / 2]); expectVector(rotated.value.location, confirmed.value.location); record('scaleModal', await transformNative(page, 's', null, '2')); const scaled = await readModelingMain(page, name); record('scaled', scaled); expectVector(scaled.value.scale, before.value.scale.map((value: number) => value * 2)); expectVector(scaled.value.rotation, rotated.value.rotation); expectVector(scaled.value.location, confirmed.value.location); await nativeKeyFrame(page, 'Tab', 9); record('editEntered', await waitModelingMode(page, name, 'EDIT')); await nativeKeyFrame(page, 'Alt+a', 65); const deselected = await readModelingMain(page, name); expect(deselected.value.vertices.every((vertex: any) => !vertex.selected)).toBe(true); const target = await projectNearestVertex(page, name); record('vertexTarget', target); record('vertexClick', await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true }, () => page.mouse.click(target.point.x, target.point.y))); await waitNativeModal(page, false); const selection = await readModelingMain(page, name); record('vertexSelected', selection); expect(selection.value.vertices.filter((vertex: any) => vertex.selected).map((vertex: any) => vertex.index)).toEqual([target.index]); record('vertexModal', await transformNative(page, 'g', 'z', '1')); const edited = await readModelingMain(page, name); record('vertexEdited', edited); for (const key of ['location', 'rotation', 'scale']) expectVector(edited.value[key], scaled.value[key]); for (const vertex of selection.value.vertices) { const actual = edited.value.vertices.find((item: any) => item.index === vertex.index); expect(actual).toBeDefined(); expect(actual.selected).toBe(vertex.selected); expectVector(actual.world, vertex.world.map((value: number, axis: number) => value + (vertex.index === target.index && axis === 2 ? 1 : 0))); } record('undoFrame', await nativeKeyFrame(page, 'Control+z', 90)); const undone = await waitModelingMode(page, name, 'EDIT'); record('undone', undone); expectModelingData(undone.value, selection.value); record('redoFrame', await nativeKeyFrame(page, 'Control+Shift+z', 90)); const redone = await waitModelingMode(page, name, 'EDIT'); record('redone', redone); expectModelingData(redone.value, edited.value); await nativeKeyFrame(page, 'Tab', 9); const final = await waitModelingMode(page, name, 'OBJECT'); record('finalObject', final); await exposeWebTools(page); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const download = await downloading, saved = testInfo.outputPath('b2-native.blend'); expect(download.suggestedFilename().endsWith('.blend')).toBe(true); await download.saveAs(saved); record('saved', saved); await focusNativeViewport(page); record('unsavedModal', await transformNative(page, 'g', 'x', '5')); const unsaved = await readModelingMain(page, name); record('unsaved', unsaved); expect(unsaved.value.location[0]).toBeCloseTo(final.value.location[0] + 5, 5); await exposeWebTools(page); await openFromUI(page, async () => { await page.getByTestId('blend-file-input').setInputFiles(saved); await resolveProjectReplacement(page, 'discard'); }); const reopened = await readModelingMain(page, name); record('reopened', reopened); expectModelingData(reopened.value, final.value); await showNativeObjectProperties(page); const transformGroup = page.getByRole('group', { name: `${name}: Transform`, exact: true }); await transformGroup.scrollIntoViewIfNeeded(); record('uiLayout', await transformGroup.getByRole('spinbutton', { name: 'Location X', exact: true }).evaluate(element => { const result = []; for (let node: Element | null = element; node; node = node.parentElement) { const style = getComputedStyle(node); result.push({ tag: node.tagName, className: node.className, rect: node.getBoundingClientRect().toJSON(), scrollHeight: node.scrollHeight, clientHeight: node.clientHeight, scrollTop: node.scrollTop, overflow: style.overflow, display: style.display, flex: style.flex }); } return result; })); for (const axis of ['X', 'Y', 'Z']) await expect(transformGroup.getByRole('spinbutton', { name: `Location ${axis}`, exact: true })).toBeInViewport({ ratio: 1 }); record('reopenedUI', await expectUILocation(page, name, final.value.location)); await page.screenshot({ path: testInfo.outputPath('b2-reopened-ui.png') }); record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, name, saved, editedVertex: target.index }); }); test.afterEach(async ({ page }, testInfo) => { const state = await captureNativeWorkflowState(page).catch(error => ({ captureError: String(error) })); fs.writeFileSync(testInfo.outputPath('b2-last-native-state.json'), JSON.stringify(state, null, 2)); });