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>
56 lines
12 KiB
TypeScript
56 lines
12 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import fs from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto';
|
|
import { observeNative, captureNativeWorkflowState, exposeWebTools, performNativeAction, resolveProjectReplacement } from '../fixtures/native-ui-workflow';
|
|
import { focusNativeViewport, nativeKeyFrame, queryNativeJSON, readNativeHistory, readNativeBlend, transformNative, waitNativeModal } from '../fixtures/native-modeling-workflow';
|
|
import { openFromUI, showNativeObjectProperties, expectUILocation } from '../fixtures/wasm-console-workflow';
|
|
const root = path.resolve(import.meta.dirname, '../../..'), config = JSON.parse(fs.readFileSync(path.join(root, 'tests/golden/WBV2-P1424-001/b2-source-ui-20261028/case-plan.json'), 'utf8'));
|
|
const binding = config.fixtures.find((item: any) => item.kind === process.env.WBV2_CASE_KIND); if (!binding) throw new Error('Source case fixture binding missing'); const baseline = JSON.parse(fs.readFileSync(path.resolve(root, binding.desktopBaseline.snapshotPath), 'utf8')), snapshotSource = fs.readFileSync(path.join(root, 'tests/golden/WBV2-P1424-001/b2-source-ui-20261028/b2_snapshot.py'), 'utf8');
|
|
test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('b2-source-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ error: String(error) })), null, 2)); });
|
|
test('B2 source UI modeling matches desktop states and cancels without mutation', async ({ page }, info) => {
|
|
const evidence: any = { sourceCaseId: binding.sourceCaseId, desktopBaseline: binding.desktopBaseline, baselineDrift: baseline.baselineDrift, stages: {} }; let phase = 'baseline_binding';
|
|
const record = (name: string, value: unknown) => { evidence.stages[name] = value; fs.writeFileSync(info.outputPath('b2-source-ui.json'), JSON.stringify(evidence, null, 2)); };
|
|
const read = () => queryNativeJSON(page, snapshotSource + `\nprint(json.dumps(snapshot(${config.snapshotSchema.floatDecimals}, preserved=${JSON.stringify(binding.preservedScenes ?? [])})))`);
|
|
const check = async (stage: string) => { phase = stage; const expected = baseline.snapshots.find((item: any) => item.checkpoint === stage); expect(expected).toBeDefined(); const actual = await read(); record(stage, { desktop: expected, web: actual }); expect(actual.value).toEqual(expected.state); return actual; };
|
|
try {
|
|
expect(path.resolve(process.env.WBV2_CASE_FIXTURE!)).toBe(path.resolve(root, binding.path)); expect(createHash('sha256').update(fs.readFileSync(path.resolve(root, binding.desktopBaseline.snapshotPath))).digest('hex')).toBe(binding.desktopBaseline.snapshotSha256); expect(baseline.sourceCaseId).toBe(binding.sourceCaseId); expect(baseline.desktopBaseline.sourceTreeSha256).toBe(binding.desktopBaseline.sourceTreeSha256); expect(['CAPTURED_MATCHES_SOURCE', 'baseline_drift']).toContain(baseline.status);
|
|
await observeNative(page); await page.goto('/?ui=web');
|
|
await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-open-status', 'SUCCEEDED', { timeout: 60000 });
|
|
phase = 'file_open'; await exposeWebTools(page); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(path.resolve(process.env.WBV2_CASE_FIXTURE!)));
|
|
const preferences = `import bpy,json\nsettings=json.loads(${JSON.stringify(JSON.stringify(config.sourcePreferences))})\nfor name,value in settings.items():\n group,attribute=name.split('.'); setattr(getattr(bpy.context.preferences,group),attribute,value)\nprint(json.dumps({name:getattr(getattr(bpy.context.preferences,name.split('.')[0]),name.split('.')[1]) for name in settings}))`;
|
|
phase = 'source_preferences'; const prepared = await queryNativeJSON(page, preferences); record('source_preferences', prepared); expect(prepared.value).toEqual(config.sourcePreferences); await check('initial');
|
|
let { canvas } = await focusNativeViewport(page); const { NativeUIWorkflow } = await import('../fixtures/native-ui-workflow'); const flow = new NativeUIWorkflow(page, info.outputPath('menu-receipts'));
|
|
const menu = async (entry: any) => {
|
|
phase = `menu_${entry.operatorId}`; await canvas.focus(); const start = await page.evaluate(() => (window as any).__nativeWorkflowRequests.length), dialog = page.getByRole('dialog', { name: '操作搜索', exact: true }); await page.keyboard.press('F3');
|
|
await expect.poll(async () => await dialog.isVisible() || await page.evaluate(index => (window as any).__nativeWorkflowRequests.slice(index).some((request: any) => request.type === 'gpuUI' && request.keyPressed === 114), start), { timeout: 10000 }).toBe(true);
|
|
if (!await dialog.isVisible()) { const request = await page.evaluate(index => (window as any).__nativeWorkflowRequests.slice(index).find((item: any) => item.type === 'gpuUI' && item.keyPressed === 114), start); const handle = await page.waitForFunction(id => (window as any).__nativeWorkflowResponses.find((item: any) => item.requestId === id) ?? false, request.requestId, { timeout: 60000 }); const response: any = await handle.jsonValue(); expect(response.ok, response.errorMessage).toBe(true); }
|
|
if (await dialog.isVisible()) {
|
|
await dialog.getByRole('textbox', { name: '搜索 Blender 操作' }).fill(entry.operatorId); const choices = dialog.getByRole('button').filter({ hasText: '[WEB_MAIN]' }); expect(await choices.count(), `B3_WEB_WIRING: ${entry.operatorId} has no native Web route`).toBeGreaterThan(0); await choices.first().click(); const form = dialog.locator('form'); await expect.poll(async () => await dialog.isHidden() || await form.isVisible()).toBe(true);
|
|
if (await form.isVisible()) { for (const [name, value] of Object.entries(entry.properties ?? {})) { const input = form.getByLabel(name, { exact: true }); if (await input.count() && await input.isVisible()) await input.fill(String(value)); } await flow.click(dialog.getByRole('button', { name: '执行', exact: true })); }
|
|
await expect(dialog).toBeHidden(); ({ canvas } = await focusNativeViewport(page)); record(`menu_${entry.operatorId}`, { route: 'BROWSER_EQUIVALENT_WEB_MAIN', sourceText: entry.sourceText });
|
|
} else { await page.keyboard.type(entry.sourceText.replaceAll(' -> ', ' ▸ ')); const receipt = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter')); await waitNativeModal(page, false, receipt.requestId); record(`menu_${entry.operatorId}`, { route: 'NATIVE_MENU_SEARCH', receipt }); }
|
|
};
|
|
phase = 'source_setup'; await nativeKeyFrame(page, 'Shift+F5', 116); await nativeKeyFrame(page, 'Control+Alt+Space', 32); ({ canvas } = await focusNativeViewport(page));
|
|
await nativeKeyFrame(page, 'a', 65); await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 46 }, () => page.keyboard.press('Delete')); await nativeKeyFrame(page, 'Enter', 13); const cleared = await read(); record('source_cleared', cleared); expect(cleared.value).toEqual(baseline.snapshots.find((item: any) => item.checkpoint === 'undone').state);
|
|
await menu(config.webMenus.addPlane); const added = await read(); record('added', added); expect(added.value.objects).toHaveLength(1); expect(added.value.objects[0].vertexCount).toBe(4);
|
|
phase = 'duplicate_rotate'; for (let index = 0; index < 3; index++) { await canvas.focus(); await page.keyboard.press('Shift+d'); await waitNativeModal(page, true); await page.keyboard.press('x'); await page.keyboard.type('3');
|
|
const receipt = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter')); await waitNativeModal(page, false, receipt.requestId); record(`rotate_${index}`, await transformNative(page, 'r', 'z', '15'));
|
|
} await check('duplicated_rotated');
|
|
await nativeKeyFrame(page, 'a', 65); await nativeKeyFrame(page, 'Numpad7', 103); await nativeKeyFrame(page, 'NumpadDecimal', 110); await nativeKeyFrame(page, 'Control+j', 74); await check('joined');
|
|
await nativeKeyFrame(page, 'Tab', 9); await menu(config.webMenus.subdivide); await nativeKeyFrame(page, 'Tab', 9); await check('subdivided');
|
|
phase = 'source_undo'; for (let index = 0; index < 12; index++) await nativeKeyFrame(page, 'Control+z', 90); await check('undone');
|
|
phase = 'source_redo'; for (let index = 0; index < 12; index++) await nativeKeyFrame(page, 'Control+Shift+z', 90); const redone = await check('redone');
|
|
phase = 'modal_cancel'; const historyBefore = await readNativeHistory(page), bytesBefore = await readNativeBlend(page); await canvas.focus(); await page.keyboard.press('g'); const started = await waitNativeModal(page, true); await page.keyboard.press('x'); await page.keyboard.type('2');
|
|
const cancelledReceipt = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 27 }, () => page.keyboard.press('Escape')); await waitNativeModal(page, false, cancelledReceipt.requestId);
|
|
const cancelled = await read(), historyAfter = await readNativeHistory(page), bytesAfter = await readNativeBlend(page); const cancelCase = config.supplementaryCases.find((item: any) => item.sourceCaseId === 'TRANSFORM_OT_translate.modal_cancel'); expect(cancelCase).toBeDefined(); record('cancelled', { sourceCase: cancelCase, executionRoute: 'WEB_MAIN', started, receipt: cancelledReceipt, before: redone, after: cancelled, historyBefore, historyAfter, blendBytesEqual: bytesBefore.equals(bytesAfter) });
|
|
expect(cancelled.value).toEqual(redone.value); expect(historyAfter.cursor).toBe(historyBefore.cursor); expect(historyAfter.entries).toEqual(historyBefore.entries); expect(bytesAfter.equals(bytesBefore)).toBe(true);
|
|
phase = 'restore_area'; await nativeKeyFrame(page, 'Control+Alt+Space', 32); const final = await read(); record('before_file', final); expect(final.value).toEqual(redone.value);
|
|
phase = 'file_save'; await exposeWebTools(page); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const download = await downloading, saved = info.outputPath('b2-source-native.blend'); expect(download.suggestedFilename().endsWith('.blend')).toBe(true); await download.saveAs(saved); record('saved', { path: saved, route: 'REUSED_B1_WEB_SAVE' });
|
|
phase = 'unsaved_change'; ({ canvas } = await focusNativeViewport(page)); record('unsaved_move', await transformNative(page, 'g', 'x', '5')); const unsaved = await read(); record('unsaved', unsaved); expect(unsaved.value.objects.find((item: any) => item.name === final.value.activeObject).transform.location[0]).toBeCloseTo(final.value.objects.find((item: any) => item.name === final.value.activeObject).transform.location[0] + 5, 5);
|
|
phase = 'file_reopen'; await exposeWebTools(page); await openFromUI(page, async () => { await page.getByTestId('blend-file-input').setInputFiles(saved); await resolveProjectReplacement(page, 'discard'); }); const reopened = await read(); record('reopened', reopened); expect(reopened.value).toEqual(final.value);
|
|
phase = 'visible_readback'; await showNativeObjectProperties(page); const active = final.value.objects.find((item: any) => item.name === final.value.activeObject); expect(active).toBeDefined(); record('visible_location', await expectUILocation(page, active.name, active.transform.location)); await page.screenshot({ path: info.outputPath('b2-source-reopened-ui.png') });
|
|
record('result', { status: 'PASS_SOURCE_UI_MODELING_SCOPE', kind: binding.kind, sourceCaseId: binding.sourceCaseId, saved, cancel: 'state_bytes_and_history_restored', sourceUndoRedo: '12_steps_each', fullB2Acceptance: 'NOT_CLAIMED', remaining: ['Retain original Cube/standalone-scale compatibility gates and all global residuals'] });
|
|
} catch (error) {
|
|
const wiring = phase.startsWith('menu_') || phase.startsWith('file_') || phase === 'initial' || String(error).includes('B3_WEB_WIRING'); record('failure', { phase, owner: wiring ? 'B3' : 'B2', category: wiring ? 'web_wiring' : phase === 'modal_cancel' ? 'modal_cancel_semantics' : 'modeling_state_requires_source_diagnosis', failureReason: String(error), baselineDrift: baseline.baselineDrift }); throw error;
|
|
}
|
|
});
|