Files
Genarrative/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts
T
suzmii 14283db943
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
修复运行页「刷新运行画面」不是真正重载
- 刷新时给 iframe 地址加一枚随 reloadNonce 递增的 __agc_reload 参数:同一地址的导航可能命中缓存,只换元素身份不足以保证重新请求
- 重新载入时丢掉上一版量到的内容尺寸,新页面先在容器尺寸上适配,不再被上一版的适配结果渲染
- tests/localGamePreviewFrame.test.ts 补刷新回归:换新节点 + 地址带一次性参数 + 丢弃旧测量
- tests/runPreviewRefresh.test.tsx 断言改为新契约(原地址逐字保留、参数逐次递增、节点每次都是新的)
2026-10-05 17:08:21 +08:00

468 lines
13 KiB
TypeScript

// @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,
currentIframe() {
return view.container.querySelector('iframe') as HTMLIFrameElement | null;
},
setReloadNonce(nonce: number) {
act(() => {
view.rerender(
createElement(LocalGamePreviewFrame, {
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
title: 'preview',
reloadNonce: nonce,
}),
);
});
},
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('刷新运行画面:换新节点、地址带一次性参数,并丢掉上一版的测量结果按容器重新适配', () => {
// 「刷新没生效、切走再切回来才好」的两个缺口都在这里钉住:只换元素身份时,量到的内容尺寸还留在
// 组件里(新页面被上一版的尺寸渲染),地址也不带变化(同一地址的导航可能直接吃缓存)。
const frame = renderFittedFrame({ width: 800, height: 500 });
frame.reportSize({
contentWidth: 800,
contentHeight: 835,
viewportWidth: 800,
viewportHeight: 500,
});
expect(frame.iframe.style.height).toBe('835px');
expect(frame.iframe.getAttribute('src')).toBe('http://127.0.0.1:1234/');
frame.setReloadNonce(1);
const reloaded = frame.currentIframe();
expect(reloaded).not.toBe(frame.iframe);
expect(
new URL(String(reloaded?.getAttribute('src'))).searchParams.get(
'__agc_reload',
),
).toBe('1');
// 上一版量到的 835px 不许带进新页面:先按容器适配,等新页面自己上报。
expect(reloaded?.style.width).toBe('800px');
expect(reloaded?.style.height).toBe('500px');
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();
});
});