Files
Genarrative/apps/ai-game-creator-shell/tests/runtimeInspectEngines.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

1511 lines
45 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 {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { parseLocalGamePreviewInspectMessage } from '../src/features/project-workspace/LocalGamePreviewFrame';
import { repoPath } from './repoPath';
/**
* 运行画面点选桥(`src-tauri/resources/preview/local-preview-fit.js`)的引擎档用例。
*
* 桥脚本随 Rust 源码 `include_str!` 打包,这里用 fs 读同一份文件、`new Function` 执行,
* 因此断言的就是随包进客户端的那段代码。jsdom 里 `window.parent` 就是自己,spy 它的
* `postMessage` 即可拿到 `genarrative.local-preview-inspect.v1` 载荷。
*
* 引擎句柄契约:`window.__GENARRATIVE_PREVIEW_GAME__`(Phaser `{ engine, game }` /
* three `{ engine, THREE, scene, camera, renderer }`),桥端惰性读取、只认显式句柄。
*/
const BRIDGE_SOURCE = readFileSync(
repoPath(
'apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js',
),
'utf8',
);
const INSPECT_MESSAGE = 'genarrative.local-preview-inspect.v1';
/** 本地预览桥自己的消息协议;测试按契约直接读,不做逐字段复检。 */
type BridgePayload = {
type?: string;
action?: string;
selection?: Record<string, unknown>;
};
/**
* 只给这个属性一个宽松视图:`window` 上同名属性的全局声明由脚手架 smoke 页面维护
* (`src/smokePickingGame*.ts`,形态是真 Phaser / three 实例),这里放的是假引擎对象,
* 用 `unknown` 双断言避开那层声明,不依赖也不修改它。
*/
type PreviewBridgeWindow = { __GENARRATIVE_PREVIEW_GAME__?: unknown };
type StubRect = { left: number; top: number; width: number; height: number };
type FakeBounds = { x: number; y: number; width: number; height: number };
type FakeSpriteTexture = {
key: string;
getSourceImage?: () => { currentSrc?: string; src?: string };
source?: { image: { currentSrc?: string; src?: string } }[];
};
type FakeSprite = {
visible: boolean;
depth: number;
name: string;
type: string;
texture: FakeSpriteTexture;
getBounds: () => FakeBounds;
getData: (key: string) => string | undefined;
};
type FakePointer = {
x: number;
y: number;
worldX?: number;
worldY?: number;
camera?: unknown;
};
type FakePhaserCamera = {
scrollX: number;
scrollY: number;
zoom: number;
x: number;
y: number;
width: number;
height: number;
originX: number;
originY: number;
rotation: number;
/** Phaser 4 的权威屏幕换算矩阵(`Camera.preRender` 组装,`getWorldPoint` 求它的逆)。 */
matrixCombined?: {
transformPoint: (
x: number,
y: number,
out: { x: number; y: number },
) => { x: number; y: number };
};
/** `sortGameObjects` 用的渲染顺序表:索引越大越靠上。 */
renderList?: unknown[];
getWorldPoint: (x: number, y: number) => { x: number; y: number };
};
type FakePhaserScene = {
sys: { isActive: () => boolean };
cameras: { main: FakePhaserCamera };
input: { hitTestPointer: (pointer: FakePointer) => unknown[] };
children: { list: unknown[] };
};
const posted: BridgePayload[] = [];
const bridgeWindow = window as unknown as PreviewBridgeWindow;
const spriteData = new WeakMap<object, Record<string, string>>();
/** 假网格的包围盒:桥只经 THREE.Box3.setFromObject 取,这里按对象登记。 */
const meshBox = new WeakMap<
object,
{
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
}
>();
function createSprite(options: {
bounds: StubRect;
data?: Record<string, string>;
textureKey?: string;
sourceImage?: { currentSrc?: string; src?: string };
textureSource?: { currentSrc?: string; src?: string };
depth?: number;
visible?: boolean;
name?: string;
objectType?: string;
}): FakeSprite {
const texture: FakeSpriteTexture = { key: options.textureKey ?? '' };
const sourceImage = options.sourceImage;
if (sourceImage) texture.getSourceImage = () => sourceImage;
const textureSource = options.textureSource;
if (textureSource) texture.source = [{ image: textureSource }];
const sprite: FakeSprite = {
visible: options.visible ?? true,
depth: options.depth ?? 0,
name: options.name ?? '',
type: options.objectType ?? '',
texture,
getBounds: () => ({
x: options.bounds.left,
y: options.bounds.top,
width: options.bounds.width,
height: options.bounds.height,
}),
getData: (key: string) => spriteData.get(sprite)?.[key],
};
spriteData.set(sprite, options.data ?? {});
return sprite;
}
function createPhaserCamera(
overrides: Partial<FakePhaserCamera> = {},
): FakePhaserCamera {
return {
scrollX: 0,
scrollY: 0,
zoom: 1,
x: 0,
y: 0,
width: 800,
height: 600,
originX: 0.5,
originY: 0.5,
rotation: 0,
getWorldPoint: (x: number, y: number) => ({ x, y }),
...overrides,
};
}
function createPhaserScene(options: {
active: boolean;
camera?: FakePhaserCamera;
interactiveHits?: unknown[];
children?: unknown[];
}): FakePhaserScene {
const camera = options.camera ?? createPhaserCamera();
return {
sys: { isActive: () => options.active },
cameras: { main: camera },
// 与 Phaser 一致:hitTestPointer 命中后会把 pointer.camera 设成参与命中的相机。
input: {
hitTestPointer: (pointer: FakePointer) => {
const hits = options.interactiveHits ?? [];
if (hits.length > 0) pointer.camera = camera;
return hits;
},
},
children: { list: options.children ?? [] },
};
}
function createPhaserGame(options: {
canvas: HTMLCanvasElement;
pointer?: FakePointer | null;
scenes: FakePhaserScene[];
}) {
return {
canvas: options.canvas,
input: { activePointer: options.pointer ?? null },
scene: { scenes: options.scenes },
// 与 ScaleManager.transformX/Y 同式:canvasBounds 取 canvas 可见矩形,displayScale 按 1:1
// (假 canvas 的 CSS 尺寸就是游戏尺寸)。
scale: {
gameSize: { width: 800, height: 600 },
transformX: (pageX: number) =>
pageX - options.canvas.getBoundingClientRect().left,
transformY: (pageY: number) =>
pageY - options.canvas.getBoundingClientRect().top,
},
};
}
type FakeThreeHit = { object: unknown; distance: number };
type FakeVector3 = {
x: number;
y: number;
z: number;
project: (camera: unknown) => FakeVector3;
};
type FakeThreeNamespace = {
Vector2: new (x: number, y: number) => { x: number; y: number };
Vector3: new (x: number, y: number, z: number) => FakeVector3;
Box3: new () => {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
setFromObject: (object: unknown) => unknown;
};
Raycaster: new () => {
setFromCamera: (ndc: { x: number; y: number }, camera: unknown) => void;
intersectObjects: (objects: unknown, recursive: boolean) => FakeThreeHit[];
};
};
const threeProbe: {
ndc?: { x: number; y: number };
camera?: unknown;
objects?: unknown;
recursive?: boolean;
} = {};
/** 世界坐标 → NDC 的假投影(按 800x600 画面):断言的是桥给的归一化与取近优先。 */
const projection = (x: number, y: number) => ({
x: x / 400 - 1,
y: 1 - y / 300,
});
function createThreeNamespace(options: {
hits: FakeThreeHit[];
/** 世界坐标 → NDC 的假投影:够用即可,断言的是桥给的归一化与取近优先。 */
projection: (x: number, y: number, z: number) => { x: number; y: number };
}): FakeThreeNamespace {
class Vector2 {
constructor(
public x: number,
public y: number,
) {}
}
class Vector3 {
constructor(
public x: number,
public y: number,
public z: number,
) {}
project(): FakeVector3 {
const projected = options.projection(this.x, this.y, this.z);
this.x = projected.x;
this.y = projected.y;
return this;
}
}
class Box3 {
min = { x: 0, y: 0, z: 0 };
max = { x: 0, y: 0, z: 0 };
setFromObject(object: unknown): Box3 {
if (object && typeof object === 'object') {
const box = meshBox.get(object);
if (box) {
this.min = { ...box.min };
this.max = { ...box.max };
}
}
return this;
}
}
class Raycaster {
setFromCamera(ndc: { x: number; y: number }, camera: unknown): void {
threeProbe.ndc = ndc;
threeProbe.camera = camera;
}
intersectObjects(objects: unknown, recursive: boolean): FakeThreeHit[] {
threeProbe.objects = objects;
threeProbe.recursive = recursive;
return options.hits;
}
}
return { Vector2, Vector3, Box3, Raycaster };
}
function createThreeMesh(options: {
userData?: Record<string, unknown>;
material?: unknown;
box?: StubRect;
name?: string;
geometryType?: string;
objectType?: string;
}) {
const mesh = {
name: options.name ?? '',
type: options.objectType ?? '',
geometry:
options.geometryType === undefined ? {} : { type: options.geometryType },
userData: options.userData ?? {},
material: options.material ?? {},
};
if (options.box) {
meshBox.set(mesh, {
min: { x: options.box.left, y: options.box.top, z: 0 },
max: {
x: options.box.left + options.box.width,
y: options.box.top + options.box.height,
z: 0,
},
});
}
return mesh;
}
function stubRect(element: Element, rect: StubRect): void {
// `as unknown as DOMRect`:jsdom 里 DOMRect 构造不可用,这里给的是等价的可读矩形。
const full = {
...rect,
x: rect.left,
y: rect.top,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
toJSON: () => ({}),
} as unknown as DOMRect;
Object.defineProperty(element, 'getBoundingClientRect', {
value: () => full,
configurable: true,
});
}
function createCanvas(rect: StubRect): HTMLCanvasElement {
const canvas = document.createElement('canvas');
stubRect(canvas, rect);
document.body.appendChild(canvas);
return canvas;
}
function sendInspectAction(action: 'enable' | 'disable'): void {
window.dispatchEvent(
new MessageEvent('message', {
source: window.parent,
data: { type: INSPECT_MESSAGE, action },
}),
);
}
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 }),
);
}
function selectedSelection(): Record<string, unknown> {
const message = posted.find(
(entry) => entry.type === INSPECT_MESSAGE && entry.action === 'selected',
);
if (!message?.selection) throw new Error('未收到 selected 载荷');
return message.selection;
}
function sanitizedSelection(): Record<string, unknown> {
const parsed = parseLocalGamePreviewInspectMessage({
type: INSPECT_MESSAGE,
action: 'selected',
selection: selectedSelection(),
});
if (parsed?.action !== 'selected') throw new Error('载荷未通过净化');
return { ...parsed.selection };
}
beforeAll(() => {
window.requestAnimationFrame = (() =>
0) as typeof window.requestAnimationFrame;
window.setInterval = (() => 0) as unknown as typeof window.setInterval;
vi.spyOn(window, 'postMessage').mockImplementation(((message: unknown) => {
// 桥脚本自己的协议载荷,形状由上面的 BridgePayload 契约保证。
const payload = message as BridgePayload;
posted.push(payload);
}) as unknown as typeof window.postMessage);
// 桥脚本按字符串随包,这里执行的就是 `include_str!` 的那份代码。
// eslint-disable-next-line no-new-func
new Function(BRIDGE_SOURCE)();
});
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
posted.length = 0;
document.body.innerHTML = '';
delete bridgeWindow.__GENARRATIVE_PREVIEW_GAME__;
});
describe('运行画面点选:Phaser 4 档', () => {
it('setInteractive 命中时走 hitTestPointer,身份取显式标注,矩形取对象屏幕矩形', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 100, top: 50, width: 200, height: 100 },
data: {
genarrativeResourceId: 'local-asset:sprite',
genarrativeResourcePath: 'assets/hero.png',
},
textureKey: 'hero-tex',
});
const scene = createPhaserScene({
active: true,
interactiveHits: [sprite],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 150, y: 90 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 150, 90);
expect(selectedSelection()).toEqual({
label: 'hero',
elementTag: 'canvas',
elementRole: '',
text: '',
width: 200,
height: 100,
resourceIds: ['local-asset:sprite'],
sourcePath: '/assets/hero.png',
});
// 同一载荷过了宿主侧净化后仍然保住身份(id 不含空白与路径分隔符,sourcePath 取 pathname)。
expect(sanitizedSelection()).toEqual({
label: 'hero',
elementTag: 'canvas',
elementRole: undefined,
text: undefined,
width: 200,
height: 100,
resourceIds: ['local-asset:sprite'],
sourcePath: '/assets/hero.png',
});
});
it('未开 setInteractive 的精灵由 getBounds 与指针世界坐标求交兜底', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 320, top: 200, width: 160, height: 80 },
data: { genarrativeResourcePath: 'assets/props/barrel.png' },
});
const scene = createPhaserScene({
active: true,
interactiveHits: [],
children: [sprite],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 400, y: 240 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 400, 240);
const selection = selectedSelection();
expect(selection.label).toBe('barrel');
expect(selection.resourceIds).toEqual([]);
expect(selection.sourcePath).toBe('/assets/props/barrel.png');
expect(sanitizedSelection()).toMatchObject({
label: 'barrel',
resourceIds: [],
sourcePath: '/assets/props/barrel.png',
width: 160,
height: 80,
});
});
it('多场景只取激活场景:非激活场景的命中不参与', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const inactiveSprite = createSprite({
bounds: { left: 0, top: 0, width: 100, height: 100 },
data: { genarrativeResourceId: 'local-asset:inactive' },
});
const inactiveScene = createPhaserScene({
active: false,
interactiveHits: [inactiveSprite],
});
const activeScene = createPhaserScene({
active: true,
interactiveHits: [],
children: [],
});
const game = createPhaserGame({
canvas,
pointer: { x: 20, y: 20 },
scenes: [inactiveScene, activeScene],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game };
sendInspectAction('enable');
clickAt(canvas, 20, 20);
expect(selectedSelection().resourceIds).toEqual([]);
expect(selectedSelection().label).toBe('canvas');
// 激活场景自己命中时仍然取激活场景的对象。
const activeSprite = createSprite({
bounds: { left: 10, top: 10, width: 60, height: 60 },
data: { genarrativeResourceId: 'local-asset:active' },
});
activeScene.input.hitTestPointer = () => [activeSprite];
sendInspectAction('enable');
posted.length = 0;
clickAt(canvas, 30, 30);
expect(selectedSelection().resourceIds).toEqual(['local-asset:active']);
});
it('无显式标注时身份回落到贴图 URL,标签取文件名去扩展名', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 0, top: 0, width: 100, height: 100 },
textureKey: 'tiles',
sourceImage: {
src: 'http://localhost:3000/assets/atlas/sky-tile.png?v=3',
},
});
const scene = createPhaserScene({
active: true,
interactiveHits: [sprite],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 50, y: 50 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'sky-tile',
sourcePath: '/assets/atlas/sky-tile.png',
resourceIds: [],
});
});
it('texture.source[].image 的 currentSrc 优先于 src', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 0, top: 0, width: 100, height: 100 },
textureKey: 'frames',
textureSource: {
currentSrc: 'http://localhost:3000/assets/atlas/frame-01.png',
src: 'http://localhost:3000/assets/atlas/fallback.png',
},
});
const scene = createPhaserScene({
active: true,
interactiveHits: [sprite],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 50, y: 50 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'frame-01',
sourcePath: '/assets/atlas/frame-01.png',
});
});
it('贴图 URL 也拿不到时标签回落到 texture.key', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 0, top: 0, width: 100, height: 100 },
textureKey: 'procedural-sky',
});
const scene = createPhaserScene({
active: true,
interactiveHits: [sprite],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 50, y: 50 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'procedural-sky',
sourcePath: '',
resourceIds: [],
});
});
it('相机 matrixCombined 可用时优先走矩阵换算(四角取包围盒)', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 10, top: 20, width: 100, height: 50 },
data: { genarrativeResourceId: 'local-asset:scaled' },
});
const camera = createPhaserCamera({
// v4 的 matrixCombined 已经把 scroll/zoom/相机 x/y 全折进去(Camera.preRender 组装),
// 这里用 2 倍缩放矩阵:字段换算会给出未缩放的矩形,只有走矩阵才会是 (200,100,400,200)。
matrixCombined: {
transformPoint: (
x: number,
y: number,
out: { x: number; y: number },
) => {
out.x = x * 2;
out.y = y * 2;
return out;
},
},
});
const scene = createPhaserScene({
active: true,
camera,
interactiveHits: [sprite],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 50, y: 40 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 50, 40);
// 世界 (10,20)-(110,70) 经 2 倍矩阵 → 屏幕 (20,40)-(220,140):宽 200、高 100。
// 字段换算(zoom=1/scroll=0/camera=0)会给 (10,20,100,50),所以这就是「走了矩阵」的证据。
expect(selectedSelection()).toMatchObject({
label: 'canvas',
resourceIds: ['local-asset:scaled'],
width: 200,
height: 100,
});
sendInspectAction('enable');
moveAt(canvas, 50, 40);
const overlay = document.querySelector<HTMLElement>(
'[data-genarrative-preview-inspect="true"]',
);
expect(overlay?.style.left).toBe('20px');
expect(overlay?.style.top).toBe('40px');
expect(overlay?.style.width).toBe('200px');
expect(overlay?.style.height).toBe('100px');
});
it('没有 matrixCombined 时按相机字段换算(含 scroll / zoom / 相机 x,y)', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 10, top: 20, width: 100, height: 50 },
data: { genarrativeResourceId: 'local-asset:plain' },
});
const camera = createPhaserCamera({
scrollX: -400,
scrollY: -300,
zoom: 2,
x: 3,
y: 4,
});
const scene = createPhaserScene({
active: true,
camera,
interactiveHits: [sprite],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 50, y: 40 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 50, 40);
// 相机字段换算(origin=0.5 / zoom=2 / scroll=(-400,-300) / camera=(3,4)):
// 世界 (10,20)-(110,70) → 屏幕 (423,344)-(623,444),宽 200、高 100(世界尺寸的 2 倍)。
// 矩形落在画布内,画布裁剪不改变它。
expect(selectedSelection()).toMatchObject({
width: 200,
height: 100,
});
});
it('两个重叠对象时命中最上层(hitTestPointer 是按注册顺序返回,不是叠放顺序)', () => {
// 真实 Phaser 4.2.1 实测(Chromium + Phaser.CANVAS,depth 0/10 两个 zone 完全重叠):
// 先注册上层对象时 `hitTestPointer` 返回 ['top-depth10', 'bottom-depth0'],_list 顺序即注册顺序;
// Phaser 自己随后用 InputPlugin.sortGameObjects 按 camera.renderList 索引降序排(topOnly 取 [0])。
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const bottom = createSprite({
bounds: { left: 100, top: 100, width: 200, height: 200 },
data: { genarrativeResourceId: 'local-asset:bottom' },
});
const top = createSprite({
bounds: { left: 100, top: 100, width: 200, height: 200 },
data: { genarrativeResourceId: 'local-asset:top' },
});
const camera = createPhaserCamera({ renderList: [bottom, top] });
const scene = createPhaserScene({
active: true,
camera,
interactiveHits: [top, bottom],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 200, y: 200 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 200, 200);
expect(selectedSelection().resourceIds).toEqual(['local-asset:top']);
});
it('以本次点击坐标为准:activePointer 停在别处也命中点击位置的对象', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 600, top: 400, width: 100, height: 100 },
data: { genarrativeResourceId: 'local-asset:clicked' },
});
// 指针停在左上角(最近一次 mousemove 的位置),点击发生在右下角。
const pointer: FakePointer = { x: 5, y: 5 };
const scene = createPhaserScene({
active: true,
interactiveHits: [sprite],
});
const sceneInputCalls: { x: number; y: number }[] = [];
scene.input.hitTestPointer = (received: FakePointer) => {
sceneInputCalls.push({ x: received.x, y: received.y });
return received.x === 650 && received.y === 450 ? [sprite] : [];
};
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({ canvas, pointer, scenes: [scene] }),
};
sendInspectAction('enable');
clickAt(canvas, 650, 450);
expect(sceneInputCalls).toEqual([{ x: 650, y: 450 }]);
expect(selectedSelection().resourceIds).toEqual(['local-asset:clicked']);
// 覆写只限 hitTest 期间:结束后 activePointer(含 worldX/worldY/camera)还原。
expect(pointer).toEqual({
x: 5,
y: 5,
worldX: undefined,
worldY: undefined,
camera: undefined,
});
});
it('activePointer 缺失时退化为几何兜底,不抛错', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 300, top: 300, width: 100, height: 100 },
data: { genarrativeResourceId: 'local-asset:no-pointer' },
});
const scene = createPhaserScene({
active: true,
interactiveHits: [],
children: [sprite],
});
// 没有 activePointer(例如输入子系统未就绪)时只走几何兜底。
const game = createPhaserGame({ canvas, pointer: null, scenes: [scene] });
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game };
sendInspectAction('enable');
expect(() => clickAt(canvas, 350, 350)).not.toThrow();
expect(selectedSelection().resourceIds).toEqual(['local-asset:no-pointer']);
});
it('移动高亮跟随命中对象的屏幕矩形,换算不稳时退化为指针点矩形', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sprite = createSprite({
bounds: { left: 200, top: 120, width: 300, height: 180 },
data: { genarrativeResourceId: 'local-asset:hero' },
});
const scene = createPhaserScene({
active: true,
interactiveHits: [sprite],
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 250, y: 200 },
scenes: [scene],
}),
};
sendInspectAction('enable');
moveAt(canvas, 250, 200);
const overlay = document.querySelector<HTMLElement>(
'[data-genarrative-preview-inspect="true"]',
);
expect(overlay?.style.left).toBe('200px');
expect(overlay?.style.top).toBe('120px');
expect(overlay?.style.width).toBe('300px');
expect(overlay?.style.height).toBe('180px');
// 相机字段不可用(旋转)时不阻塞:矩形退化成 pointer 点的 1x1。
scene.cameras.main.rotation = 0.5;
moveAt(canvas, 400, 300);
const degraded = document.querySelector<HTMLElement>(
'[data-genarrative-preview-inspect="true"]',
);
expect(degraded?.style.left).toBe('400px');
expect(degraded?.style.top).toBe('300px');
expect(degraded?.style.width).toBe('1px');
expect(degraded?.style.height).toBe('1px');
});
it('对象没有贴图时标签取对象名 / 对象类型,不再一律落到 canvas', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const named = createSprite({
bounds: { left: 0, top: 0, width: 100, height: 100 },
name: 'Hero',
objectType: 'Sprite',
});
const scene = createPhaserScene({ active: true, interactiveHits: [named] });
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'phaser',
game: createPhaserGame({
canvas,
pointer: { x: 50, y: 50 },
scenes: [scene],
}),
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'Hero',
resourceIds: [],
sourcePath: '',
});
// 没有对象名时退到对象类型。
const unnamed = createSprite({
bounds: { left: 0, top: 0, width: 100, height: 100 },
objectType: 'Sprite',
});
scene.input.hitTestPointer = () => [unnamed];
sendInspectAction('enable');
posted.length = 0;
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'Sprite',
resourceIds: [],
sourcePath: '',
});
});
});
describe('运行画面点选:three.js 档', () => {
it('贴图 Mesh 命中:NDC 归一化正确,矩形由 Box3 八角投影给出', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
material: {
map: { image: { src: 'http://localhost:3000/assets/hero-mesh.png' } },
},
box: { left: 100, top: 50, width: 200, height: 100 },
});
const camera = { name: 'camera' };
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: three,
scene: { children: [mesh] },
camera,
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 200, 200);
expect(threeProbe.ndc).toEqual({ x: -0.5, y: 0.33333333333333337 });
expect(threeProbe.camera).toBe(camera);
expect(threeProbe.objects).toEqual([mesh]);
expect(threeProbe.recursive).toBe(true);
expect(selectedSelection()).toEqual({
label: 'hero-mesh',
elementTag: 'canvas',
elementRole: '',
text: '',
width: 200,
height: 100,
resourceIds: [],
sourcePath: '/assets/hero-mesh.png',
});
expect(sanitizedSelection()).toMatchObject({
label: 'hero-mesh',
sourcePath: '/assets/hero-mesh.png',
resourceIds: [],
});
});
it('userData.genarrativeResourceId 优先于贴图 URL,且贴图 URL 不做资源 id', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
userData: { genarrativeResourceId: 'local-asset:mesh' },
material: {
map: {
image: { src: 'http://localhost:3000/assets/from-texture.png' },
},
},
box: { left: 100, top: 50, width: 200, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: three,
scene: { children: [mesh] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 200, 200);
expect(selectedSelection().resourceIds).toEqual(['local-asset:mesh']);
expect(selectedSelection().sourcePath).toBe('/assets/from-texture.png');
expect(sanitizedSelection()).toMatchObject({
resourceIds: ['local-asset:mesh'],
sourcePath: '/assets/from-texture.png',
});
});
it('intersectObjects 返回多条时取最近的一条', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const near = createThreeMesh({
userData: { genarrativeResourceId: 'local-asset:near' },
box: { left: 0, top: 0, width: 100, height: 100 },
});
const far = createThreeMesh({
userData: { genarrativeResourceId: 'local-asset:far' },
box: { left: 0, top: 0, width: 100, height: 100 },
});
const three = createThreeNamespace({
hits: [
{ object: near, distance: 1 },
{ object: far, distance: 9 },
],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: three,
scene: { children: [far, near] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection().resourceIds).toEqual(['local-asset:near']);
});
it('userData.genarrativeResourcePath 优先于贴图 URL 派生标签', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
userData: { genarrativeResourcePath: 'assets/models/crate.glb' },
material: {
map: {
image: { src: 'http://localhost:3000/assets/from-texture.png' },
},
},
box: { left: 0, top: 0, width: 100, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: three,
scene: { children: [mesh] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'crate',
sourcePath: '/assets/models/crate.glb',
resourceIds: [],
});
});
it('贴图只有 texture.source.data 时也能派生源路径与标签', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
material: {
map: {
source: {
data: {
currentSrc: 'http://localhost:3000/assets/atlas/floor.png',
},
},
},
},
box: { left: 0, top: 0, width: 100, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: three,
scene: { children: [mesh] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'floor',
sourcePath: '/assets/atlas/floor.png',
resourceIds: [],
});
});
it('贴图 URL 是 blob:/data: 时不塞进 sourcePath,标签仍不为空', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
material: {
map: { image: { src: 'blob:http://localhost:3000/9f1c-uuid' } },
},
box: { left: 0, top: 0, width: 100, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: three,
scene: { children: [mesh] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'canvas',
sourcePath: '',
resourceIds: [],
});
});
it('瘦身句柄(three 只放四个构造器)可命中贴图 Mesh', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
material: {
map: { image: { src: 'http://localhost:3000/assets/slim-hero.png' } },
},
box: { left: 100, top: 50, width: 200, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
three,
scene: { children: [mesh] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 200, 200);
expect(selectedSelection()).toEqual({
label: 'slim-hero',
elementTag: 'canvas',
elementRole: '',
text: '',
width: 200,
height: 100,
resourceIds: [],
sourcePath: '/assets/slim-hero.png',
});
});
it('旧形态(整个 THREE 命名空间)仍兼容命中', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
userData: { genarrativeResourceId: 'local-asset:legacy' },
box: { left: 0, top: 0, width: 100, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
// 旧句柄把整个 `import * as THREE` 命名空间塞进来:额外成员不影响命中。
const namespace = {
...three,
Scene: function Scene() {},
Color: function Color() {},
WebGLRenderer: function WebGLRenderer() {},
};
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: namespace,
scene: { children: [mesh] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 10, 10);
expect(selectedSelection()).toMatchObject({
label: 'canvas',
resourceIds: ['local-asset:legacy'],
sourcePath: '',
});
});
it('命中对象没有素材身份时标签取 object.name', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
name: 'Crate',
geometryType: 'BoxGeometry',
objectType: 'Mesh',
box: { left: 0, top: 0, width: 100, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
three,
scene: { children: [mesh] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
// 没有任何素材身份(无 userData、材质无贴图):标签不再一律是 canvas。
expect(selectedSelection()).toMatchObject({
label: 'Crate',
resourceIds: [],
sourcePath: '',
});
expect(sanitizedSelection()).toMatchObject({
label: 'Crate',
resourceIds: [],
});
expect(sanitizedSelection().sourcePath).toBeUndefined();
});
it('没有对象名时标签取 geometry.type,几何也取不到时取 object.type', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const sphere = createThreeMesh({
geometryType: 'SphereGeometry',
objectType: 'Mesh',
box: { left: 0, top: 0, width: 100, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: sphere, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
three,
scene: { children: [sphere] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({ label: 'SphereGeometry' });
const group = createThreeMesh({
objectType: 'Group',
box: { left: 0, top: 0, width: 100, height: 100 },
});
const second = createThreeNamespace({
hits: [{ object: group, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
three: second,
scene: { children: [group] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
posted.length = 0;
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({ label: 'Group' });
});
it('句柄缺 Box3 / Vector3 等构造器时按未命中退化', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const mesh = createThreeMesh({
userData: { genarrativeResourceId: 'local-asset:incomplete' },
box: { left: 0, top: 0, width: 100, height: 100 },
});
const three = createThreeNamespace({
hits: [{ object: mesh, distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
three: { Raycaster: three.Raycaster, Vector2: three.Vector2 },
scene: { children: [mesh] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 50, 50);
expect(selectedSelection()).toMatchObject({
label: 'canvas',
resourceIds: [],
sourcePath: '',
});
});
});
describe('运行画面点选:退化路径', () => {
it('无引擎句柄的 canvas 与 DOM 档一致:标签取元素标签、无身份字段', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
sendInspectAction('enable');
clickAt(canvas, 10, 10);
expect(selectedSelection()).toEqual({
label: 'canvas',
elementTag: 'canvas',
elementRole: '',
text: '',
width: 800,
height: 600,
resourceIds: [],
sourcePath: '',
});
expect(sanitizedSelection()).toMatchObject({
label: 'canvas',
elementTag: 'canvas',
resourceIds: [],
width: 800,
height: 600,
});
expect(sanitizedSelection().sourcePath).toBeUndefined();
});
it('句柄 engine 未知时不进引擎档,按 DOM 退化', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'babylon',
game: {},
};
sendInspectAction('enable');
clickAt(canvas, 10, 10);
expect(selectedSelection()).toMatchObject({
label: 'canvas',
resourceIds: [],
sourcePath: '',
});
});
it('引擎句柄在位但命中空白:不伪造 id,退化为不带素材的区域引用', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const three = createThreeNamespace({ hits: [], projection });
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: three,
scene: { children: [] },
camera: {},
renderer: { domElement: canvas },
};
sendInspectAction('enable');
clickAt(canvas, 10, 10);
expect(selectedSelection()).toEqual({
label: 'canvas',
elementTag: 'canvas',
elementRole: '',
text: '',
width: 800,
height: 600,
resourceIds: [],
sourcePath: '',
});
});
it('three 句柄缺 renderer.domElement 时不抛错,按未命中退化', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
const three = createThreeNamespace({
hits: [{ object: createThreeMesh({}), distance: 1 }],
projection,
});
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
THREE: three,
scene: { children: [] },
camera: {},
renderer: {},
};
sendInspectAction('enable');
expect(() => clickAt(canvas, 10, 10)).not.toThrow();
expect(selectedSelection()).toMatchObject({
label: 'canvas',
resourceIds: [],
sourcePath: '',
});
});
it('Phaser 句柄缺场景/指针时退化,且点选协议仍然收尾', () => {
const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 });
bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game: {} };
sendInspectAction('enable');
clickAt(canvas, 10, 10);
expect(posted.map((entry) => entry.action)).toEqual([
'enabled',
'selected',
'disabled',
]);
expect(selectedSelection()).toMatchObject({
label: 'canvas',
resourceIds: [],
});
});
});
describe('运行画面点选:DOM 档不回归', () => {
it('img 的 src 与资源标识属性逐字保留', () => {
const img = document.createElement('img');
img.setAttribute('src', '/assets/photo.png');
img.setAttribute('data-resource-id', 'local-asset:photo');
document.body.appendChild(img);
sendInspectAction('enable');
clickAt(img, 5, 5);
expect(selectedSelection()).toEqual({
label: 'img',
elementTag: 'img',
elementRole: '',
text: '',
width: 1,
height: 1,
resourceIds: ['local-asset:photo'],
sourcePath: '/assets/photo.png',
});
expect(sanitizedSelection()).toMatchObject({
label: 'img',
elementTag: 'img',
resourceIds: ['local-asset:photo'],
sourcePath: '/assets/photo.png',
});
});
it('资源标识属性三种写法都收,顺序即声明顺序', () => {
const region = document.createElement('div');
region.setAttribute('data-resource-id', 'local-asset:one');
region.setAttribute('data-asset-id', 'local-asset:two');
region.setAttribute('data-genarrative-resource-id', 'local-asset:three');
document.body.appendChild(region);
sendInspectAction('enable');
clickAt(region, 1, 1);
expect(selectedSelection().resourceIds).toEqual([
'local-asset:one',
'local-asset:two',
'local-asset:three',
]);
});
it('子节点 img|video|audio 的 src 参与来源路径', () => {
const region = document.createElement('div');
const img = document.createElement('img');
img.setAttribute('src', '/assets/inner.png');
region.appendChild(img);
document.body.appendChild(region);
sendInspectAction('enable');
clickAt(region, 1, 1);
expect(selectedSelection()).toMatchObject({
label: 'div',
elementTag: 'div',
sourcePath: '/assets/inner.png',
resourceIds: [],
});
});
it('Esc 取消与 disable 的协议不变', () => {
const region = document.createElement('div');
document.body.appendChild(region);
sendInspectAction('enable');
expect(posted.map((entry) => entry.action)).toEqual(['enabled']);
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
);
expect(posted.map((entry) => entry.action)).toEqual([
'enabled',
'cancelled',
]);
sendInspectAction('disable');
expect(posted.map((entry) => entry.action)).toEqual([
'enabled',
'cancelled',
]);
});
});