// @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; }; /** * 只给这个属性一个宽松视图:`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>(); /** 假网格的包围盒:桥只经 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; 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 { 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; 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 { 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 { 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( '[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( '[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( '[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', ]); }); });