diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx index 4a069da20..b64824c24 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx @@ -16,15 +16,53 @@ const PATCHED_GEOMETRY = [ 'scrollHeight', 'clientHeight', 'getBoundingClientRect', + 'scrollTo', ] as const; +/** + * 假 ResizeObserver:记录回调,用例自己决定何时模拟一次布局变化。 + * + * jsdom 没有 ResizeObserver,也不做布局,滚动补偿的唯一入口只能靠它驱动。 + */ +class FakeResizeObserver { + private static callbacks: Array<() => void> = []; + private readonly callback: () => void; + + constructor(callback: () => void) { + this.callback = callback; + FakeResizeObserver.callbacks.push(callback); + } + + observe(): void {} + + unobserve(): void {} + + disconnect(): void { + FakeResizeObserver.callbacks = FakeResizeObserver.callbacks.filter( + (item) => item !== this.callback, + ); + } + + static reset(): void { + FakeResizeObserver.callbacks = []; + } + + /** 模拟一次「列表子元素高度变了」:展开、图片撑高、历史前插都走这条路。 */ + static triggerLayoutChange(): void { + act(() => { + for (const callback of [...FakeResizeObserver.callbacks]) callback(); + }); + } +} + /** * jsdom 不做布局:几何量全部挂到 `HTMLDivElement.prototype` 上,用例自己给数字。 * * 挂在原型而不是实例上,是因为列表元素由 React 在渲染时创建,挂载期读几何的效果必须 * 一开始就能读到这些值。`afterEach` 删掉这些自有属性即可落回 jsdom 的原实现。 */ -function stubListMetrics(metrics: ListMetrics): void { +function stubListMetrics(metrics: ListMetrics): { writes: number } { + const counter = { writes: 0 }; Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', { configurable: true, get: () => metrics.scrollHeight, @@ -37,6 +75,7 @@ function stubListMetrics(metrics: ListMetrics): void { configurable: true, get: () => metrics.scrollTop, set: (value: number) => { + counter.writes += 1; metrics.scrollTop = value; }, }); @@ -55,6 +94,26 @@ function stubListMetrics(metrics: ListMetrics): void { toJSON: () => ({}), }) as DOMRect, }); + return counter; +} + +/** + * 平滑滚动在 jsdom 里没有实现:只记录目标位置。 + * + * 真实浏览器里写一次 `scrollTop` 就会取消动画,而 jsdom 不会——所以「动画被打断」这件事 + * 只能靠「有没有人写 `scrollTop`」来断言,不能靠位置收敛来断言。 + */ +function stubSmoothScroll(): { targets: number[] } { + const calls = { targets: [] as number[] }; + Object.defineProperty(HTMLDivElement.prototype, 'scrollTo', { + configurable: true, + value: (options?: ScrollToOptions | number) => { + calls.targets.push( + typeof options === 'number' ? options : (options?.top ?? 0), + ); + }, + }); + return calls; } function Harness({ @@ -82,11 +141,19 @@ function Harness({ }); return ( <> + {/* 一个回合块:前插锚点与「往哪儿补偿」都要有真实可定位的元素。 */}
+
+
+