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 记一条「高亮框被尺寸上报当成页面内容」的根因、口径与回归
1511 lines
45 KiB
TypeScript
1511 lines
45 KiB
TypeScript
// @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',
|
||
]);
|
||
});
|
||
});
|