258501918f
## 问题 AGC 项目运行页的本地预览会根据 iframe 上报内容尺寸调整 iframe viewport;部分使用 `100vh`、百分比或响应式布局的游戏会把宿主调整后的 viewport 再反映为新的内容尺寸,形成反馈环并在两个缩放档位之间持续抖动。 附件工程和录屏已确认可触发该问题,普通浏览器直接打开不受影响。 ## 落地方案 - 补充宿主 viewport 变更与真实内容变更的区分,忽略由宿主首次应用 fit 引起的回灌尺寸; - 保留同一稳定 viewport 下真实动态内容变化后的重新适配; - 增加反馈环收敛、动态内容变化和陈旧消息回归; - 同步 AGC 运行预览权威文档和项目记忆。 ## 验证计划 - 附件 `gameagent-2873e5ac` 在 AGC WebView 中稳定显示; - 相关 TypeScript 单测与类型检查; - preview Rust 定向测试; - `npm run check:encoding`、`git diff --check`。 Fixes #250 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/252 Reviewed-by: 段舒康 <kdletters@qq.com> Co-authored-by: 董羽秦 <suzmii@qq.com> Co-committed-by: 董羽秦 <suzmii@qq.com>
351 lines
9.2 KiB
TypeScript
351 lines
9.2 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,
|
|
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();
|
|
});
|
|
});
|