16361bfbe1
Project CI / AI game creator shell Rust crates (pull_request) Successful in 59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 2m5s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m40s
Project CI / Native shell tests (pull_request) Successful in 5m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m24s
- `resolveLocalGamePreviewFitLayout` 增加回退:上报的内容尺寸等于它被接受时的容器尺寸(自适应页面把视口原样报回)时不算内容高水位,直接按容器取画布——修复「全屏预览退出后画布仍保持全屏比例、永久带黑边」;真比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。 - 该回退的已知残余(固定尺寸页面恰好等于接受时的容器且之后不上报新尺寸会被裁)与「内容尺寸没变就改认新容器」这条更差尝试一起写进代码注释与 decision-log;根治方向记在桥/协议侧。 - `tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具,补「退出全屏回到容器尺寸」(改前必红)与「容器缩小后按新内容尺寸重新适配」两条用例。 - 信息栏手动态改为按资源 id 在渲染期派生(去掉 effect 回写),修掉「刚有内容那一帧先画收起态」的抖动,并让 `onClick` 写入的字段与派生读的字段一致。 - AGC 子集删掉恒真的「数值微调面板不存在」断言,保留「卡片不渲染」与「开合行不渲染」两条独立事实。 - 文档同步:PRD §3.4、技术方案与 decision-log 的信息栏判据口径改为「资源选中态」,并记录全屏回归的根因、修法、残余边界与桥协议根治方向;2026-08-23 适配条目补一条指向本次收口。
425 lines
12 KiB
TypeScript
425 lines
12 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,
|
|
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();
|
|
});
|
|
});
|