// @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, LocalGamePreviewFrame, parseLocalGamePreviewContentSize, resolveLocalGamePreviewContentSizeUpdate, resolveLocalGamePreviewFitLayout, } from '../src/features/project-workspace/LocalGamePreviewFrame'; describe('local game preview viewport fitting', () => { it('does not reset the fitted iframe to native size while its container resizes', () => { let containerRect = { width: 800, height: 500 }; 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; act(() => { window.dispatchEvent( new MessageEvent('message', { origin: 'http://127.0.0.1:1234', source: iframe.contentWindow, data: { type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, contentWidth: 800, contentHeight: 835, viewportWidth: 800, viewportHeight: 500, }, }), ); }); expect(iframe.style.height).toBe('835px'); containerRect = { width: 1000, height: 600 }; act(() => { if (!resizeCallback) throw new Error('ResizeObserver was not registered'); resizeCallback([], {} as ResizeObserver); }); expect(iframe.style.width).toBe('1000px'); expect(iframe.style.height).toBe('835px'); view.unmount(); window.ResizeObserver = previousResizeObserver; rectSpy.mockRestore(); }); 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(); }); });