7ad412f0ec
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- 根因:高亮框(body 下 data-genarrative-preview-inspect,position:fixed)被尺寸上报的 measureContentBounds 遍历计入内容尺寸;three 投影盒超出画布时把内容尺寸撑到 3756×2643(视口 900×560),宿主改 iframe 尺寸触发游戏 resize 重算相机,形成自激振荡 - 桥:measureContentBounds 的 TreeWalker 对 data-genarrative-preview-fit / data-genarrative-preview-inspect 返回 FILTER_REJECT(桥自己的节点不参与内容测量) - 桥:新增 clipInspectRect,引擎档(three 投影盒、Phaser 世界包围盒)命中矩形统一裁剪到画布可见范围;与画布无交集时退化为指针点矩形(不出现 0/负尺寸) - 测试:新增 localPreviewInspectSizeStability.test.ts(5 例:桥节点不参与测量、同尺寸普通节点仍计入的对照组、进入检查模式与 hover 后尺寸与宿主适配布局不变、投影盒超出画布时载荷被裁剪、与画布无交集时退化为 1×1);runtimeInspectEngines 的字段换算用例改到画布内的等价参数(原矩形落在画布外,裁剪后按新规则退化为指针点矩形) - 文档:pitfalls 记一条「高亮框被尺寸上报当成页面内容」的根因、口径与回归
524 lines
16 KiB
TypeScript
524 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import { resolveLocalGamePreviewFitLayout } from '../src/features/project-workspace/LocalGamePreviewFrame';
|
||
import { repoPath } from './repoPath';
|
||
|
||
/**
|
||
* 「点选不改变预览尺寸」的回归用例。
|
||
*
|
||
* 用户实测 BUG:three 项目里点选 3D 对象后预览画面自己缩放/视角异常。根因是桥自己的高亮框
|
||
* (`data-genarrative-preview-inspect`,`position: fixed`,尺寸随命中对象)被尺寸上报的
|
||
* `measureContentBounds` 当成页面固有内容 → 宿主据此改 iframe 尺寸 → 游戏 `resize` 重算相机。
|
||
* 这里在 jsdom 里加载**真实桥脚本**、用假 `window.parent` 收尺寸消息,断言:
|
||
* 高亮框(以及任何桥自己的节点)出现、移动、消失都不改变尺寸上报与宿主算出的适配布局。
|
||
*/
|
||
const BRIDGE_SOURCE = readFileSync(
|
||
repoPath(
|
||
'apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js',
|
||
),
|
||
'utf8',
|
||
);
|
||
const SIZE_MESSAGE = 'genarrative.local-preview-size.v1';
|
||
const INSPECT_MESSAGE = 'genarrative.local-preview-inspect.v1';
|
||
const VIEWPORT = { width: 900, height: 560 };
|
||
|
||
type SizePayload = {
|
||
type?: string;
|
||
contentWidth?: number;
|
||
contentHeight?: number;
|
||
viewportWidth?: number;
|
||
viewportHeight?: number;
|
||
};
|
||
|
||
type Posted = SizePayload & {
|
||
action?: string;
|
||
selection?: Record<string, unknown>;
|
||
};
|
||
|
||
type StubRect = { left: number; top: number; width: number; height: number };
|
||
|
||
const posted: Posted[] = [];
|
||
const rectOverrides = new WeakMap<Element, StubRect>();
|
||
|
||
function fakeRect(rect: StubRect) {
|
||
return {
|
||
...rect,
|
||
x: rect.left,
|
||
y: rect.top,
|
||
right: rect.left + rect.width,
|
||
bottom: rect.top + rect.height,
|
||
toJSON: () => ({}),
|
||
};
|
||
}
|
||
|
||
function stubRect(element: Element, rect: StubRect): void {
|
||
rectOverrides.set(element, rect);
|
||
}
|
||
|
||
function sizeMessages(): SizePayload[] {
|
||
return posted.filter((message) => message.type === SIZE_MESSAGE);
|
||
}
|
||
|
||
function lastSize(): SizePayload {
|
||
const messages = sizeMessages();
|
||
const message = messages[messages.length - 1];
|
||
if (!message) throw new Error('未收到尺寸上报');
|
||
return message;
|
||
}
|
||
|
||
function lastSelection(): Record<string, unknown> {
|
||
const message = [...posted]
|
||
.reverse()
|
||
.find(
|
||
(entry) => entry.type === INSPECT_MESSAGE && entry.action === 'selected',
|
||
);
|
||
if (!message?.selection) throw new Error('未收到点选载荷');
|
||
return message.selection;
|
||
}
|
||
|
||
/** 触发一次尺寸上报:桥按 sizeKey 去重,先制造一次不同的内容尺寸再回到当前尺寸。 */
|
||
function publishSize(): SizePayload {
|
||
const scratch = document.createElement('div');
|
||
stubRect(scratch, { left: 0, top: 0, width: 12345, height: 12345 });
|
||
document.body.appendChild(scratch);
|
||
window.dispatchEvent(new Event('resize'));
|
||
scratch.remove();
|
||
window.dispatchEvent(new Event('resize'));
|
||
const messages = sizeMessages();
|
||
if (messages.length === 0) throw new Error('未收到尺寸上报');
|
||
return messages[messages.length - 1] as SizePayload;
|
||
}
|
||
|
||
function overlayElement(): HTMLElement | null {
|
||
return document.querySelector('[data-genarrative-preview-inspect="true"]');
|
||
}
|
||
|
||
function overlayStyleRect() {
|
||
const overlay = overlayElement();
|
||
if (!overlay) return null;
|
||
return [
|
||
overlay.style.left,
|
||
overlay.style.top,
|
||
overlay.style.width,
|
||
overlay.style.height,
|
||
];
|
||
}
|
||
|
||
function sendInspectAction(action: 'enable' | 'disable'): void {
|
||
window.dispatchEvent(
|
||
new MessageEvent('message', {
|
||
source: window.parent as unknown as MessageEventSource,
|
||
data: { type: INSPECT_MESSAGE, action },
|
||
}),
|
||
);
|
||
}
|
||
|
||
function createCanvas(): HTMLCanvasElement {
|
||
const canvas = document.createElement('canvas');
|
||
stubRect(canvas, { left: 0, top: 0, ...VIEWPORT });
|
||
document.body.appendChild(canvas);
|
||
return canvas;
|
||
}
|
||
|
||
function clickAt(target: Element, clientX: number, clientY: number): void {
|
||
target.dispatchEvent(
|
||
new MouseEvent('click', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clientX,
|
||
clientY,
|
||
}),
|
||
);
|
||
}
|
||
|
||
function moveAt(target: Element, clientX: number, clientY: number): void {
|
||
target.dispatchEvent(
|
||
new MouseEvent('mousemove', { bubbles: true, clientX, clientY }),
|
||
);
|
||
}
|
||
|
||
/** 假 three:够桥走完射线 + 八角投影。 */
|
||
function createThreeFake(options: {
|
||
hits: { object: unknown; distance: number }[];
|
||
/** 世界坐标 → 屏幕坐标(与页面同尺度),用来造出「投影盒超出画布」。 */
|
||
projection: (x: number, y: number) => { x: number; y: number };
|
||
}) {
|
||
const meshBox = new WeakMap<
|
||
object,
|
||
{ min: { x: number; y: number }; max: { x: number; y: number } }
|
||
>();
|
||
class Vector2 {
|
||
constructor(
|
||
public x: number,
|
||
public y: number,
|
||
) {}
|
||
}
|
||
class Vector3 {
|
||
constructor(
|
||
public x: number,
|
||
public y: number,
|
||
public z: number,
|
||
) {}
|
||
project(): this {
|
||
const point = options.projection(this.x, this.y);
|
||
this.x = point.x;
|
||
this.y = point.y;
|
||
return this;
|
||
}
|
||
}
|
||
class Box3 {
|
||
min = { x: 0, y: 0, z: 0 };
|
||
max = { x: 0, y: 0, z: 0 };
|
||
setFromObject(object: unknown): this {
|
||
if (object && typeof object === 'object') {
|
||
const box = meshBox.get(object);
|
||
if (box) {
|
||
this.min = { ...box.min, z: 0 };
|
||
this.max = { ...box.max, z: 0 };
|
||
}
|
||
}
|
||
return this;
|
||
}
|
||
}
|
||
class Raycaster {
|
||
setFromCamera(): void {}
|
||
intersectObjects(): { object: unknown; distance: number }[] {
|
||
return options.hits;
|
||
}
|
||
}
|
||
return {
|
||
namespace: { Vector2, Vector3, Box3, Raycaster },
|
||
createMesh(
|
||
object: object,
|
||
box: { min: { x: number; y: number }; max: { x: number; y: number } },
|
||
) {
|
||
meshBox.set(object, box);
|
||
return object;
|
||
},
|
||
};
|
||
}
|
||
|
||
function stubDocumentMetrics(): void {
|
||
Object.defineProperty(window, 'innerWidth', {
|
||
value: VIEWPORT.width,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, 'innerHeight', {
|
||
value: VIEWPORT.height,
|
||
configurable: true,
|
||
});
|
||
// 真实 AGC 游戏页面不留 body 外边距;否则桥把 margin 计入固有尺寸(900 → 916)。
|
||
document.body.style.margin = '0';
|
||
document.documentElement.style.margin = '0';
|
||
for (const element of [document.documentElement, document.body]) {
|
||
Object.defineProperty(element, 'offsetWidth', {
|
||
value: VIEWPORT.width,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(element, 'offsetHeight', {
|
||
value: VIEWPORT.height,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(element, 'scrollWidth', {
|
||
value: VIEWPORT.width,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(element, 'scrollHeight', {
|
||
value: VIEWPORT.height,
|
||
configurable: true,
|
||
});
|
||
stubRect(element, { left: 0, top: 0, ...VIEWPORT });
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
posted.length = 0;
|
||
document.body.innerHTML = '';
|
||
stubDocumentMetrics();
|
||
// 桥只在 window.parent !== window 时上报尺寸:这里放一个假的父窗口记录 postMessage。
|
||
Object.defineProperty(window, 'parent', {
|
||
value: { postMessage: (message: Posted) => posted.push(message) },
|
||
configurable: true,
|
||
});
|
||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(((
|
||
callback: FrameRequestCallback,
|
||
) => {
|
||
callback(0);
|
||
return 0;
|
||
}) as unknown as typeof window.requestAnimationFrame);
|
||
vi.spyOn(window, 'setInterval').mockImplementation(
|
||
(() => 0) as unknown as typeof window.setInterval,
|
||
);
|
||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
|
||
function (this: Element): DOMRect {
|
||
const override = rectOverrides.get(this);
|
||
if (override) return fakeRect(override) as unknown as DOMRect;
|
||
if (
|
||
this instanceof HTMLElement &&
|
||
this.dataset?.genarrativePreviewInspect !== undefined
|
||
) {
|
||
// 真实浏览器里高亮框的 border-box = inline style 的 left/top/size + 2px 边框。
|
||
const px = (value: string) => Number.parseFloat(value) || 0;
|
||
return fakeRect({
|
||
left: px(this.style.left),
|
||
top: px(this.style.top),
|
||
width: px(this.style.width) + 4,
|
||
height: px(this.style.height) + 4,
|
||
}) as unknown as DOMRect;
|
||
}
|
||
return fakeRect({
|
||
left: 0,
|
||
top: 0,
|
||
width: 0,
|
||
height: 0,
|
||
}) as unknown as DOMRect;
|
||
},
|
||
);
|
||
if (
|
||
document.querySelector('[data-genarrative-preview-fit="true"]') === null
|
||
) {
|
||
// eslint-disable-next-line no-new-func
|
||
new Function(BRIDGE_SOURCE)();
|
||
}
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('运行画面点选不改变预览尺寸', () => {
|
||
it('尺寸上报把桥自己的节点排除在内容测量之外(同样大小的普通节点仍会被计入)', () => {
|
||
const baseline = publishSize();
|
||
expect(baseline.contentWidth).toBe(VIEWPORT.width);
|
||
expect(baseline.contentHeight).toBe(VIEWPORT.height);
|
||
|
||
// 对照组:普通节点撑开内容 → 上报必须变化(证明这条用例能抓住「内容尺寸被撑大」)。
|
||
const plain = document.createElement('div');
|
||
stubRect(plain, { left: 0, top: 0, width: 4000, height: 3000 });
|
||
document.body.appendChild(plain);
|
||
const widened = publishSize();
|
||
expect(widened.contentWidth).toBe(4000);
|
||
expect(widened.contentHeight).toBe(3000);
|
||
plain.remove();
|
||
|
||
// 实验组:同样的尺寸,但带桥自己的标记 → 不得参与测量。
|
||
const bridgeOwned = document.createElement('div');
|
||
bridgeOwned.dataset.genarrativePreviewInspect = 'true';
|
||
stubRect(bridgeOwned, { left: 0, top: 0, width: 4000, height: 3000 });
|
||
document.body.appendChild(bridgeOwned);
|
||
const afterBridgeNode = publishSize();
|
||
expect(afterBridgeNode.contentWidth).toBe(VIEWPORT.width);
|
||
expect(afterBridgeNode.contentHeight).toBe(VIEWPORT.height);
|
||
bridgeOwned.remove();
|
||
});
|
||
|
||
it('进入检查模式与 hover 出现高亮框后:尺寸上报与宿主适配布局都不变', () => {
|
||
const canvas = createCanvas();
|
||
const mesh = {
|
||
name: 'Tower',
|
||
type: 'Mesh',
|
||
geometry: { type: 'CylinderGeometry' },
|
||
userData: {},
|
||
material: {},
|
||
};
|
||
const three = createThreeFake({
|
||
hits: [{ object: mesh, distance: 1 }],
|
||
projection: (x, y) => ({
|
||
x: (x / VIEWPORT.width) * 2 - 1,
|
||
y: 1 - (y / VIEWPORT.height) * 2,
|
||
}),
|
||
});
|
||
// 投影盒远超画布:真实项目里贴近相机的大物体就是这样。
|
||
three.createMesh(mesh, {
|
||
min: { x: -400, y: -300 },
|
||
max: { x: 1300, y: 860 },
|
||
});
|
||
(
|
||
window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown }
|
||
).__GENARRATIVE_PREVIEW_GAME__ = {
|
||
engine: 'three',
|
||
three: three.namespace,
|
||
scene: { children: [mesh] },
|
||
camera: {},
|
||
renderer: { domElement: canvas },
|
||
};
|
||
|
||
const baseline = publishSize();
|
||
expect(sizeMessages().length).toBeGreaterThan(0);
|
||
expect(
|
||
resolveLocalGamePreviewFitLayout(VIEWPORT, {
|
||
contentWidth: baseline.contentWidth as number,
|
||
contentHeight: baseline.contentHeight as number,
|
||
viewportWidth: baseline.viewportWidth as number,
|
||
viewportHeight: baseline.viewportHeight as number,
|
||
}),
|
||
).toEqual({ width: VIEWPORT.width, height: VIEWPORT.height, scale: 1 });
|
||
|
||
sendInspectAction('enable');
|
||
moveAt(canvas, 450, 280);
|
||
// 高亮框被裁剪到画布内:不再是超出画面的投影盒。
|
||
expect(overlayStyleRect()).toEqual(['0px', '0px', '900px', '560px']);
|
||
|
||
const afterHover = publishSize();
|
||
expect(afterHover.contentWidth).toBe(baseline.contentWidth);
|
||
expect(afterHover.contentHeight).toBe(baseline.contentHeight);
|
||
expect(
|
||
resolveLocalGamePreviewFitLayout(VIEWPORT, {
|
||
contentWidth: afterHover.contentWidth as number,
|
||
contentHeight: afterHover.contentHeight as number,
|
||
viewportWidth: afterHover.viewportWidth as number,
|
||
viewportHeight: afterHover.viewportHeight as number,
|
||
}),
|
||
).toEqual({ width: VIEWPORT.width, height: VIEWPORT.height, scale: 1 });
|
||
|
||
clickAt(canvas, 450, 280);
|
||
const afterClick = publishSize();
|
||
expect(afterClick.contentWidth).toBe(baseline.contentWidth);
|
||
expect(afterClick.contentHeight).toBe(baseline.contentHeight);
|
||
expect(overlayElement()).toBeNull();
|
||
});
|
||
|
||
it('three 投影盒超出画布时:载荷矩形被裁剪到画布内,width/height 不为 0', () => {
|
||
const canvas = createCanvas();
|
||
const mesh = {
|
||
name: 'Tower',
|
||
type: 'Mesh',
|
||
geometry: { type: 'CylinderGeometry' },
|
||
userData: {},
|
||
material: {},
|
||
};
|
||
const three = createThreeFake({
|
||
hits: [{ object: mesh, distance: 1 }],
|
||
projection: (x, y) => ({
|
||
x: (x / VIEWPORT.width) * 2 - 1,
|
||
y: 1 - (y / VIEWPORT.height) * 2,
|
||
}),
|
||
});
|
||
three.createMesh(mesh, {
|
||
min: { x: -400, y: -300 },
|
||
max: { x: 1300, y: 860 },
|
||
});
|
||
(
|
||
window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown }
|
||
).__GENARRATIVE_PREVIEW_GAME__ = {
|
||
engine: 'three',
|
||
three: three.namespace,
|
||
scene: { children: [mesh] },
|
||
camera: {},
|
||
renderer: { domElement: canvas },
|
||
};
|
||
|
||
sendInspectAction('enable');
|
||
clickAt(canvas, 450, 280);
|
||
|
||
expect(lastSelection()).toMatchObject({
|
||
label: 'Tower',
|
||
elementTag: 'canvas',
|
||
width: VIEWPORT.width,
|
||
height: VIEWPORT.height,
|
||
resourceIds: [],
|
||
sourcePath: '',
|
||
});
|
||
});
|
||
|
||
it('投影盒与画布完全无交集时退化为指针点矩形,不产生 0 或负尺寸', () => {
|
||
const canvas = createCanvas();
|
||
const mesh = {
|
||
name: 'Far',
|
||
type: 'Mesh',
|
||
geometry: { type: 'BoxGeometry' },
|
||
userData: {},
|
||
material: {},
|
||
};
|
||
const three = createThreeFake({
|
||
hits: [{ object: mesh, distance: 1 }],
|
||
projection: (x, y) => ({
|
||
x: (x / VIEWPORT.width) * 2 - 1,
|
||
y: 1 - (y / VIEWPORT.height) * 2,
|
||
}),
|
||
});
|
||
three.createMesh(mesh, {
|
||
min: { x: 2000, y: 1500 },
|
||
max: { x: 2600, y: 1900 },
|
||
});
|
||
(
|
||
window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown }
|
||
).__GENARRATIVE_PREVIEW_GAME__ = {
|
||
engine: 'three',
|
||
three: three.namespace,
|
||
scene: { children: [mesh] },
|
||
camera: {},
|
||
renderer: { domElement: canvas },
|
||
};
|
||
|
||
sendInspectAction('enable');
|
||
clickAt(canvas, 120, 90);
|
||
|
||
expect(lastSelection()).toMatchObject({ width: 1, height: 1 });
|
||
});
|
||
|
||
it('Phaser 世界包围盒超出画布时同样裁剪到画布内', () => {
|
||
const canvas = createCanvas();
|
||
const sprite = {
|
||
name: 'Big',
|
||
type: 'Sprite',
|
||
visible: true,
|
||
depth: 0,
|
||
texture: { key: 'big' },
|
||
getBounds: () => ({ x: -500, y: -400, width: 2000, height: 1600 }),
|
||
getData: () => undefined,
|
||
};
|
||
const scene = {
|
||
sys: { isActive: () => true },
|
||
cameras: {
|
||
main: {
|
||
scrollX: 0,
|
||
scrollY: 0,
|
||
zoom: 1,
|
||
x: 0,
|
||
y: 0,
|
||
width: VIEWPORT.width,
|
||
height: VIEWPORT.height,
|
||
originX: 0.5,
|
||
originY: 0.5,
|
||
rotation: 0,
|
||
getWorldPoint: (x: number, y: number) => ({ x, y }),
|
||
},
|
||
},
|
||
input: {
|
||
hitTestPointer: (pointer: { camera?: unknown }) => {
|
||
pointer.camera = scene.cameras.main;
|
||
return [sprite];
|
||
},
|
||
},
|
||
children: { list: [sprite] },
|
||
};
|
||
(
|
||
window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown }
|
||
).__GENARRATIVE_PREVIEW_GAME__ = {
|
||
engine: 'phaser',
|
||
game: {
|
||
input: { activePointer: { x: 450, y: 280 } },
|
||
scene: { scenes: [scene] },
|
||
scale: {
|
||
gameSize: { width: VIEWPORT.width, height: VIEWPORT.height },
|
||
transformX: (pageX: number) => pageX,
|
||
transformY: (pageY: number) => pageY,
|
||
},
|
||
},
|
||
};
|
||
|
||
sendInspectAction('enable');
|
||
clickAt(canvas, 450, 280);
|
||
|
||
expect(lastSelection()).toMatchObject({
|
||
label: 'big',
|
||
width: VIEWPORT.width,
|
||
height: VIEWPORT.height,
|
||
});
|
||
});
|
||
});
|