// @vitest-environment jsdom import { act, render } from '@testing-library/react'; import { createElement } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { LOCAL_GAME_PREVIEW_SIZE_MESSAGE, type LocalGamePreviewContentSize, LocalGamePreviewFrame, parseLocalGamePreviewContentSize, resolveLocalGamePreviewContentSizeUpdate, resolveLocalGamePreviewFitLayout, } from '../src/features/project-workspace/LocalGamePreviewFrame'; /** * 运行画面用 `getBoundingClientRect` 量容器、用 `ResizeObserver` 跟踪;jsdom 两者都不给,这里补一份 * 最小可驱动的:能改容器矩形、能手放 ResizeObserver 回调、能送跨窗口尺寸上报。 */ function renderFittedFrame(initialContainer: { width: number; height: number; }) { let containerRect = initialContainer; let resizeCallback: ResizeObserverCallback | null = null; const rectSpy = vi .spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementation( () => ({ ...containerRect, x: 0, y: 0, top: 0, right: containerRect.width, bottom: containerRect.height, left: 0, toJSON: () => ({}), }) as DOMRect, ); const previousResizeObserver = window.ResizeObserver; window.ResizeObserver = class { constructor(callback: ResizeObserverCallback) { resizeCallback = callback; } observe() {} unobserve() {} disconnect() {} }; const view = render( createElement(LocalGamePreviewFrame, { preview: { status: 'running', url: 'http://127.0.0.1:1234/' }, title: 'preview', }), ); const iframe = view.getByTitle('preview') as HTMLIFrameElement; return { iframe, resizeTo(next: { width: number; height: number }) { containerRect = next; act(() => { if (!resizeCallback) { throw new Error('ResizeObserver was not registered'); } resizeCallback([], {} as ResizeObserver); }); }, reportSize(size: LocalGamePreviewContentSize) { act(() => { window.dispatchEvent( new MessageEvent('message', { origin: 'http://127.0.0.1:1234', source: iframe.contentWindow, data: { type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, ...size }, }), ); }); }, cleanup() { view.unmount(); window.ResizeObserver = previousResizeObserver; rectSpy.mockRestore(); }, }; } describe('local game preview viewport fitting', () => { it('does not reset the fitted iframe to native size while its container resizes', () => { const frame = renderFittedFrame({ width: 800, height: 500 }); frame.reportSize({ contentWidth: 800, contentHeight: 835, viewportWidth: 800, viewportHeight: 500, }); expect(frame.iframe.style.height).toBe('835px'); frame.resizeTo({ width: 1000, height: 600 }); expect(frame.iframe.style.width).toBe('1000px'); expect(frame.iframe.style.height).toBe('835px'); frame.cleanup(); }); it('returns the fitted iframe to the container after the host viewport shrinks', () => { // 全屏预览把运行视口放大到 1416x808,自适应游戏把视口原样报回来;退出全屏后画布必须跟着 // 缩回容器尺寸,不能把全屏那一帧的尺寸钉在画布上(改前这里会一直是 1416x808 + 缩放到 0.72)。 const frame = renderFittedFrame({ width: 1416, height: 808 }); frame.reportSize({ contentWidth: 1416, contentHeight: 808, viewportWidth: 1416, viewportHeight: 808, }); expect(frame.iframe.style.width).toBe('1416px'); expect(frame.iframe.style.height).toBe('808px'); frame.resizeTo({ width: 1015, height: 660 }); expect(frame.iframe.style.width).toBe('1015px'); expect(frame.iframe.style.height).toBe('660px'); frame.cleanup(); }); it('refits to the reported content size after the container shrinks', () => { // 容器缩小后先按容器取画布(上一次的内容尺寸已不能代表当前容器),页面在新容器上重新量出 // 更大的内容(真的溢出)时,画布回到 `max(容器, 内容)` 并把整幅内容等比缩小。 const frame = renderFittedFrame({ width: 1015, height: 660 }); frame.reportSize({ contentWidth: 1015, contentHeight: 660, viewportWidth: 1015, viewportHeight: 660, }); expect(frame.iframe.style.width).toBe('1015px'); frame.resizeTo({ width: 800, height: 520 }); expect(frame.iframe.style.width).toBe('800px'); frame.reportSize({ contentWidth: 1200, contentHeight: 900, viewportWidth: 800, viewportHeight: 520, }); expect(frame.iframe.style.width).toBe('1200px'); expect(frame.iframe.style.height).toBe('900px'); expect( Number(/scale\(([\d.]+)\)/u.exec(frame.iframe.style.transform)?.[1]), ).toBeCloseTo(520 / 900, 10); frame.cleanup(); }); it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => { const nativeViewport = { width: 1200, height: 700 }; const appliedViewport = { width: 1200, height: 1000 }; const current = { contentWidth: 1200, contentHeight: 1000, viewportWidth: 1200, viewportHeight: 700, }; const firstAppliedViewportReport = { contentWidth: 1200, contentHeight: 700, viewportWidth: 1200, viewportHeight: 1000, }; expect( resolveLocalGamePreviewContentSizeUpdate( current, firstAppliedViewportReport, nativeViewport, appliedViewport, nativeViewport, ), ).toBe(current); }); it('does not let a delayed native report restart the fitted viewport loop', () => { const nativeViewport = { width: 1200, height: 700 }; const appliedViewport = { width: 1200, height: 1000 }; const current = { contentWidth: 1200, contentHeight: 1000, viewportWidth: 1200, viewportHeight: 700, }; const delayedNativeReport = { contentWidth: 1200, contentHeight: 1000, viewportWidth: 1200, viewportHeight: 700, }; expect( resolveLocalGamePreviewContentSizeUpdate( current, delayedNativeReport, nativeViewport, appliedViewport, appliedViewport, ), ).toBe(current); }); it('keeps the current fit while a resized container applies its next viewport', () => { const resizedContainer = { width: 1000, height: 600 }; const current = { contentWidth: 800, contentHeight: 835, viewportWidth: 800, viewportHeight: 500, }; const appliedViewport = resolveLocalGamePreviewFitLayout( resizedContainer, current, ); const resizedViewportReport = { contentWidth: 1000, contentHeight: 818, viewportWidth: appliedViewport.width, viewportHeight: appliedViewport.height, }; expect(appliedViewport).toEqual({ width: 1000, height: 835, scale: 600 / 835, }); expect( resolveLocalGamePreviewContentSizeUpdate( current, resizedViewportReport, resizedContainer, appliedViewport, { width: 800, height: 835 }, ), ).toBe(current); }); it('keeps a game at native size when its content fits', () => { expect( resolveLocalGamePreviewFitLayout( { width: 1200, height: 700 }, { contentWidth: 1200, contentHeight: 700, viewportWidth: 1200, viewportHeight: 700, }, ), ).toEqual({ width: 1200, height: 700, scale: 1 }); }); it('scales the complete game document into the available viewport', () => { expect( resolveLocalGamePreviewFitLayout( { width: 1200, height: 700 }, { contentWidth: 1200, contentHeight: 1000, viewportWidth: 1200, viewportHeight: 700, }, ), ).toEqual({ width: 1200, height: 1000, scale: 0.7 }); }); it('accepts the native viewport report and keeps viewport out of fit state', () => { const nativeViewport = { width: 1200, height: 700 }; const report = { contentWidth: 1200, contentHeight: 1000, viewportWidth: 1200, viewportHeight: 700, }; expect( resolveLocalGamePreviewContentSizeUpdate( null, report, nativeViewport, nativeViewport, ), ).toEqual(report); }); it('accepts changed content after the fitted iframe viewport has stabilized', () => { const nativeViewport = { width: 1200, height: 700 }; const current = { contentWidth: 1200, contentHeight: 1000, viewportWidth: 1200, viewportHeight: 700, }; const changed = { contentWidth: 1200, contentHeight: 1400, viewportWidth: 1200, viewportHeight: 1000, }; expect( resolveLocalGamePreviewContentSizeUpdate( current, changed, nativeViewport, { width: 1200, height: 1000, }, { width: 1200, height: 1000 }, ), ).toEqual({ contentWidth: 1200, contentHeight: 1400, viewportWidth: 1200, viewportHeight: 700, }); }); it('shrinks the fitted frame when content becomes shorter inside a stable applied viewport', () => { const nativeViewport = { width: 1200, height: 700 }; const current = { contentWidth: 1200, contentHeight: 1000, viewportWidth: 1200, viewportHeight: 700, }; const changed = { contentWidth: 1200, contentHeight: 800, viewportWidth: 1200, viewportHeight: 1000, }; const updated = resolveLocalGamePreviewContentSizeUpdate( current, changed, nativeViewport, { width: 1200, height: 1000 }, { width: 1200, height: 1000 }, ); expect(updated).toEqual({ contentWidth: 1200, contentHeight: 800, viewportWidth: 1200, viewportHeight: 700, }); expect(resolveLocalGamePreviewFitLayout(nativeViewport, updated)).toEqual({ width: 1200, height: 800, scale: 0.875, }); }); it('keeps the current state for repeated content dimensions and fit viewport feedback', () => { const nativeViewport = { width: 1200, height: 700 }; const current = { contentWidth: 1200, contentHeight: 1400, viewportWidth: 1200, viewportHeight: 700, }; expect( resolveLocalGamePreviewContentSizeUpdate( current, { ...current, viewportHeight: 1400 }, nativeViewport, { width: 1200, height: 1400 }, ), ).toBe(current); }); it('ignores delayed reports from a stale fitted viewport', () => { const nativeViewport = { width: 1200, height: 700 }; const current = { contentWidth: 1200, contentHeight: 1400, viewportWidth: 1200, viewportHeight: 700, }; expect( resolveLocalGamePreviewContentSizeUpdate( current, { contentWidth: 1200, contentHeight: 1100, viewportWidth: 1200, viewportHeight: 1000, }, nativeViewport, { width: 1200, height: 1400 }, ), ).toBe(current); }); it('rejects malformed or unrelated cross-frame messages', () => { expect( parseLocalGamePreviewContentSize({ type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, contentWidth: 1200, contentHeight: Number.NaN, viewportWidth: 1200, viewportHeight: 700, }), ).toBeNull(); expect( parseLocalGamePreviewContentSize({ type: 'unrelated', contentWidth: 1200, contentHeight: 700, viewportWidth: 1200, viewportHeight: 700, }), ).toBeNull(); }); });