From 238c3c60d75780f22c91b4bcf40b62e182247785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 18:18:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=82=B9=E5=9B=9E?= =?UTF-8?q?=E5=88=B0=E5=BA=95=E9=83=A8=E5=90=8E=E5=B9=B3=E6=BB=91=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=87=AA=E8=BA=AB=E8=A1=A5?= =?UTF-8?q?=E5=81=BF=E6=89=93=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 programmaticScrollRef:动画在飞时滚动事件不翻转跟随、布局补偿整段跳过 - 只有真的还要走一段距离才接管滚动位置;滚到贴底或滚轮 / 触摸 / 键盘接手即交还 - 补齐 hook 用例:动画途中胶囊不闪回、不写 scrollTop、落位后与被打断后照常判定 Co-authored-by: Junie --- .../useConversationScroll.test.tsx | 146 +++++++++++++++++- .../useConversationScroll.ts | 43 +++++- 2 files changed, 187 insertions(+), 2 deletions(-) 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 ( <> + {/* 一个回合块:前插锚点与「往哪儿补偿」都要有真实可定位的元素。 */}
+
+
+