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>
50 lines
14 KiB
TypeScript
50 lines
14 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import fs from 'node:fs'; import path from 'node:path';
|
|
import { observeNative, performNativeAction, captureNativeWorkflowState, exposeWebTools } from '../fixtures/native-ui-workflow';
|
|
import { queryNativeJSON, readNativeHistory, readNativeBlend, focusNativeViewport, transformNative, waitNativeModal } from '../fixtures/native-modeling-workflow';
|
|
import { openFromUI, showNativeObjectProperties, expectUILocation } from '../fixtures/wasm-console-workflow';
|
|
const root = path.resolve(import.meta.dirname, '../../..'), fixture = path.resolve(root, process.env.WBV2_CASE_FIXTURE ?? 'tests/golden/WBV2-P1424-001/fixtures-b4-world-readback/minimal.blend'), metadata = JSON.parse(fs.readFileSync(fixture.replace(/\.blend$/, '.json'), 'utf8')), objectName = metadata.materialObjects[0];
|
|
test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('b5-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ error: String(error) })), null, 2)); });
|
|
test('B5 native keyframes curves playback and Action switching survive file recovery', async ({ page }, info) => {
|
|
const evidence: Record<string, unknown> = {}, record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(info.outputPath('b5-animation-ui.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 openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(fixture)); await waitNativeModal(page, false);
|
|
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 });
|
|
const initialNativeFrame = await page.evaluate(() => (window as any).__nativeWorkflowGPU?.requestId); await exposeWebTools(page); await waitNativeModal(page, false, initialNativeFrame);
|
|
const selectObject = () => page.getByRole('treeitem').filter({ hasText: objectName }).click(); await selectObject();
|
|
const read = async () => { const gpu = await page.evaluate(() => (window as any).__nativeWorkflowGPU); if (gpu?.attached) await waitNativeModal(page, false, gpu.requestId); return queryNativeJSON(page, `import bpy,json\no=bpy.data.objects[${JSON.stringify(objectName)}]\na=o.animation_data.action if o.animation_data else None\ncurves=list(a.fcurves) if a and hasattr(a,'fcurves') else []\nif a:\n for layer in a.layers:\n for strip in layer.strips:\n for bag in getattr(strip,'channelbags',[]): curves.extend(bag.fcurves)\nu={(f.data_path,f.array_index):f for f in curves}\nprint(json.dumps({'frame':bpy.context.scene.frame_current,'action':a.name if a else None,'actionFakeUser':a.use_fake_user if a else None,'slotHandle':(o.animation_data.action_slot_handle if o.animation_data else None),'actionSlots':([] if not a else [{'handle':sl.handle,'identifier':sl.identifier,'idRootType':getattr(sl,'id_root_type',None)} for sl in a.slots]),'actions':sorted(x.name for x in bpy.data.actions),'location':list(o.location),'evaluated':list(o.evaluated_get(bpy.context.evaluated_depsgraph_get()).matrix_world.translation),'curves':[{'path':p,'index':i,'keys':[[k.co[0],k.co[1],k.interpolation] for k in f.keyframe_points]} for (p,i),f in sorted(u.items())]}))`); };
|
|
const slider = page.getByLabel('当前帧', { exact: true }), start = Number(await slider.getAttribute('min')), end = Number(await slider.getAttribute('max')), middle = Math.floor((start + end) / 2); expect(end - start).toBeGreaterThan(2);
|
|
const seek = async (frame: number) => { await slider.focus(); const forward = frame - start <= end - frame, origin = forward ? start : end, step = forward ? 1 : -1; if (Number(await slider.inputValue()) !== origin) await performNativeAction(page, { command: 'setFrame' }, () => slider.press(forward ? 'Home' : 'End')); await expect(slider).toHaveValue(String(origin)); for (let f = origin + step; forward ? f <= frame : f >= frame; f += step) { await performNativeAction(page, { command: 'setFrame' }, () => slider.press(forward ? 'ArrowRight' : 'ArrowLeft')); await expect(slider).toHaveValue(String(f)); } await expect.poll(async () => (await read()).value.frame).toBe(frame); };
|
|
const activeAction = page.getByLabel('活动 Action', { exact: true }), motionName = `B5 ${objectName} Motion`, alternateName = `B5 ${objectName} Alternate`;
|
|
const visible = async (state: any) => { await expect(slider).toHaveValue(String(state.frame)); const option = await activeAction.locator('option').evaluateAll((items, name) => items.find(item => item.textContent === name)?.getAttribute('value'), state.action); expect(typeof option).toBe('string'); await expect(activeAction).toHaveValue(option!); await showNativeObjectProperties(page); await expectUILocation(page, objectName, state.location); };
|
|
await page.getByRole('checkbox', { name: 'Keep new Actions', exact: true }).check();
|
|
await seek(start); record('initial', await read()); await page.getByLabel('Action name', { exact: true }).fill(motionName); await performNativeAction(page, { command: 'createAction' }, () => page.getByRole('button', { name: 'New Action', exact: true }).click());
|
|
const created = await read(); expect(created.value.action).toBe(motionName); expect(created.value.actionFakeUser).toBe(true); await visible(created.value); record('created', created);
|
|
await performNativeAction(page, { command: 'insertObjectKeyframe' }, () => page.getByRole('button', { name: '插入位置关键帧', exact: true }).click()); const first = await read();
|
|
expect(first.value.curves.filter((c: any) => c.path === 'location')).toHaveLength(3); for (const curve of first.value.curves) { expect(curve.keys).toHaveLength(1); expect(curve.keys[0][0]).toBe(start); } record('firstKey', first);
|
|
await seek(end); await focusNativeViewport(page); const cancelBefore = await read(), cancelHistory = await readNativeHistory(page), cancelBytes = await readNativeBlend(page);
|
|
record('cancelBaseline', { main: cancelBefore, history: cancelHistory }); fs.writeFileSync(info.outputPath('before-cancel.blend'), cancelBytes);
|
|
await page.keyboard.press('g'); const cancelling = await waitNativeModal(page, true); await page.keyboard.press('x'); await page.keyboard.type('3'); await page.keyboard.press('Escape'); await waitNativeModal(page, false, cancelling.requestId);
|
|
const cancelled = await read(), cancelledHistory = await readNativeHistory(page), cancelledBytes = await readNativeBlend(page); record('cancelledTransform', { before: cancelBefore, cancelled, historyBefore: cancelHistory, historyAfter: cancelledHistory }); fs.writeFileSync(info.outputPath('after-cancel.blend'), cancelledBytes); expect(cancelled.value).toEqual(cancelBefore.value); expect(cancelledHistory.cursor).toBe(cancelHistory.cursor); expect(cancelledHistory.entries).toEqual(cancelHistory.entries); expect(cancelledBytes.equals(cancelBytes)).toBe(true);
|
|
await transformNative(page, 'g', 'x', '10'); const moved = await read(); expect(moved.value.evaluated[0]).toBeCloseTo(cancelBefore.value.evaluated[0] + 10, 5); record('moved', moved);
|
|
const beforeTools = await page.evaluate(() => (window as any).__nativeWorkflowGPU?.requestId); await exposeWebTools(page); await waitNativeModal(page, false, beforeTools); expect((await read()).value.location).toEqual(moved.value.location);
|
|
await performNativeAction(page, { command: 'insertObjectKeyframe' }, () => page.getByRole('button', { name: '插入位置关键帧', exact: true }).click()); const keyed = await read(); await visible(keyed.value);
|
|
for (const curve of keyed.value.curves.filter((c: any) => c.path === 'location')) { expect(curve.keys.map((k: any) => k[0])).toEqual([start, end]); expect(curve.keys[1][1]).toBeCloseTo(moved.value.location[curve.index], 5); } record('secondKey', keyed);
|
|
await performNativeAction(page, { command: 'setFCurveInterpolation' }, () => page.getByLabel('FCurve 插值', { exact: true }).selectOption('LINEAR')); const linear = await read(); expect(linear.value.curves.find((c: any) => c.path === 'location' && c.index === 0).keys.every((k: any) => k[2] === 'LINEAR')).toBe(true); expect(linear.value.curves.filter((c: any) => c.index !== 0)).toEqual(keyed.value.curves.filter((c: any) => c.index !== 0)); record('linear', linear);
|
|
await seek(middle); const interpolated = await read(), fraction = (middle - start) / (end - start), expectedX = first.value.location[0] + (moved.value.location[0] - first.value.location[0]) * fraction; expect(interpolated.value.location[0]).toBeCloseTo(expectedX, 5); expect(interpolated.value.evaluated[0]).toBeCloseTo(expectedX, 5); await visible(interpolated.value); record('middleLinear', interpolated);
|
|
await performNativeAction(page, { command: 'setFCurveInterpolation' }, () => page.getByLabel('FCurve 插值', { exact: true }).selectOption('CONSTANT')); const constant = await read(); expect(constant.value.location[0]).toBeCloseTo(first.value.location[0], 5); expect(constant.value.evaluated[0]).toBeCloseTo(first.value.location[0], 5); await visible(constant.value); record('middleConstant', constant);
|
|
await performNativeAction(page, { type: 'applyCommand', command: 'undo' }, () => page.getByRole('button', { name: '撤销', exact: true }).click()); await expect(page.getByLabel('FCurve 插值', { exact: true })).toHaveValue('LINEAR'); await seek(end); await seek(middle); const undoneCurve = await read(); expect(undoneCurve.value.location[0]).toBeCloseTo(interpolated.value.location[0], 5); await visible(undoneCurve.value); record('curveUndone', undoneCurve);
|
|
await performNativeAction(page, { type: 'applyCommand', command: 'redo' }, () => page.getByRole('button', { name: '重做', exact: true }).click()); await expect(page.getByLabel('FCurve 插值', { exact: true })).toHaveValue('CONSTANT'); await seek(end); await seek(middle); const redoneCurve = await read(); expect(redoneCurve.value.location[0]).toBeCloseTo(constant.value.location[0], 5); await visible(redoneCurve.value); record('curveRedone', redoneCurve);
|
|
await performNativeAction(page, { command: 'setFCurveInterpolation' }, () => page.getByLabel('FCurve 插值', { exact: true }).selectOption('LINEAR')); const playBefore = await read(), playHistory = await readNativeHistory(page), playBytes = await readNativeBlend(page); expect(playBefore.value.location[0]).toBeCloseTo(expectedX, 5);
|
|
await performNativeAction(page, { command: 'editEditorContext', editAction: 'TIMELINE_EDIT', editOperation: 'PLAY_FORWARD' }, () => page.getByRole('button', { name: '播放', exact: true }).click()); const cancelPlay = page.getByRole('button', { name: '取消播放并恢复起始帧', exact: true }); await expect(cancelPlay).toBeEnabled(); await expect.poll(() => slider.inputValue()).not.toBe(String(middle)); record('playbackProgress', { frame: await slider.inputValue(), locationX: await page.getByRole('group', { name: `${objectName}: Transform`, exact: true }).getByRole('spinbutton', { name: 'Location X', exact: true }).inputValue(), native: (await captureNativeWorkflowState(page)).main });
|
|
await performNativeAction(page, { command: 'editEditorContext', editAction: 'TIMELINE_EDIT', editOperation: 'CANCEL_PLAYBACK' }, () => cancelPlay.click()); await expect(cancelPlay).toBeDisabled();
|
|
const playCancelled = await read(), historyAfter = await readNativeHistory(page), bytesAfter = await readNativeBlend(page); record('playbackCancelled', { before: playBefore, after: playCancelled, historyBefore: playHistory, historyAfter }); fs.writeFileSync(info.outputPath('before-playback.blend'), playBytes); fs.writeFileSync(info.outputPath('after-playback.blend'), bytesAfter);
|
|
expect(playCancelled.value).toEqual(playBefore.value); expect({ ...historyAfter, revision: playHistory.revision }).toEqual(playHistory); expect(historyAfter.revision).toBeGreaterThanOrEqual(playHistory.revision); expect(bytesAfter.equals(playBytes)).toBe(true);
|
|
await page.getByRole('checkbox', { name: 'Keep new Actions', exact: true }).check(); await page.getByLabel('Action name', { exact: true }).fill(alternateName); await performNativeAction(page, { command: 'createAction' }, () => page.getByRole('button', { name: 'New Action', exact: true }).click()); const alternate = await read(); expect(alternate.value.action).toBe(alternateName); expect(alternate.value.actionFakeUser).toBe(true); expect(alternate.value.curves).toHaveLength(0); await visible(alternate.value); record('alternate', alternate);
|
|
await performNativeAction(page, { command: 'setActiveAction' }, () => activeAction.selectOption({ label: motionName })); const switched = await read(); expect(switched.value.action).toBe(motionName); expect(switched.value.curves).toEqual(playBefore.value.curves); expect(switched.value.location[0]).toBeCloseTo(expectedX, 5); await visible(switched.value); record('actionSwitched', switched);
|
|
await performNativeAction(page, { type: 'applyCommand', command: 'undo' }, () => page.getByRole('button', { name: '撤销', exact: true }).click()); const actionUndone = await read(); expect(actionUndone.value).toEqual(alternate.value); await visible(actionUndone.value); record('actionUndone', actionUndone);
|
|
await performNativeAction(page, { type: 'applyCommand', command: 'redo' }, () => page.getByRole('button', { name: '重做', exact: true }).click()); const actionRedone = await read(); expect(actionRedone.value).toEqual(switched.value); await visible(actionRedone.value); record('actionRedone', actionRedone);
|
|
const download = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const saved = info.outputPath('animation-roundtrip.blend'); await (await download).saveAs(saved); expect(fs.statSync(saved).size).toBeGreaterThan(12); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED');
|
|
await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(saved)); await selectObject(); const reopened = await read(); expect(reopened.value).toEqual(switched.value); await visible(reopened.value); record('reopened', reopened); await page.screenshot({ path: info.outputPath('animation-reopened.png') }); record('result', { status: 'PASS', fixture, candidate: process.env.WEB_DESKTOP_CANDIDATE_PUBLIC, saveReopenRoute: 'BROWSER_EQUIVALENT', saved, scope: 'Native key creation, curve interpolation/evaluation, playback cancel, Action switching, exact no-mutation negatives, UndoRedo and file roundtrip' });
|
|
});
|