Files
Genarrative/apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts
T
suzmii 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 记一条「高亮框被尺寸上报当成页面内容」的根因、口径与回归
2026-10-05 17:08:03 +08:00

524 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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,
});
});
});