diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx new file mode 100644 index 000000000..b9255224a --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx @@ -0,0 +1,92 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectChatTurn } from '../../conversation/directTurnPresentation'; +import { DirectProjectConversation } from './DirectProjectConversation'; + +const turn = (key: string): DirectChatTurn => ({ + key, + users: [{ kind: 'user', key: `${key}-u`, text: '做一个跳跃动作', at: 10 }], + process: [], + finals: [{ kind: 'assistant', key: `${key}-f`, text: '改好了', at: 20 }], + state: 'finished', + startedAt: 10, + endedAt: 20, +}); + +function renderConversation(overrides: { + turns?: DirectChatTurn[]; + historyHasMore?: boolean; + historyLoading?: boolean; + historyError?: string | null; + onLoadEarlierHistory?: () => void; + onRetryEarlierHistory?: () => void; +}) { + return render( + undefined)} + onRetryEarlierHistory={ + overrides.onRetryEarlierHistory ?? (() => undefined) + } + />, + ); +} + +afterEach(() => { + cleanup(); +}); + +describe('更早历史的入口', () => { + it('常驻的「显示更早的对话」按钮已经删掉,改由滚动自动加载', () => { + const view = renderConversation({ historyHasMore: true }); + expect(view.queryByText('显示更早的对话')).toBeNull(); + expect(view.container.querySelector('.message-history-more')).toBeNull(); + }); + + it('加载行延迟出现,文案是「正在加载更早的对话」', () => { + vi.useFakeTimers(); + try { + const view = renderConversation({ + historyHasMore: true, + historyLoading: true, + }); + expect(view.queryByText('正在加载更早的对话')).toBeNull(); + + act(() => { + vi.advanceTimersByTime(150); + }); + expect(view.getByText('正在加载更早的对话')).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('失败挂起后列表顶部留一行内联错误,重试是唯一的手动出路', () => { + const retry = vi.fn(); + const view = renderConversation({ + historyHasMore: true, + historyError: '读取更早的对话历史失败', + onRetryEarlierHistory: retry, + }); + + expect(view.getByText('加载更早对话失败')).not.toBeNull(); + fireEvent.click(view.getByText('重试')); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); + +describe('前插锚点的标记', () => { + it('每个块都带上所属回合的 data-turn-key', () => { + const view = renderConversation({ turns: [turn('t1')] }); + expect( + view.container.querySelectorAll('[data-turn-key="t1"]').length, + ).toBeGreaterThan(0); + }); +}); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts new file mode 100644 index 000000000..a0b25e2e2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts @@ -0,0 +1,122 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + collectTurnBlocks, + type ConversationTurnAnchor, + findTurnBlock, + readTopVisibleTurnAnchor, + restoreTurnAnchor, +} from './conversationScrollAnchor'; + +type StubRect = { top: number; bottom: number }; + +/** jsdom 不做布局:每个块的屏幕位置都由用例显式给出。 */ +function stubRect(element: HTMLElement, rect: StubRect): void { + element.getBoundingClientRect = () => + ({ + top: rect.top, + bottom: rect.bottom, + height: rect.bottom - rect.top, + left: 0, + right: 0, + width: 0, + x: 0, + y: rect.top, + toJSON: () => ({}), + }) as DOMRect; +} + +function buildList( + keys: readonly string[], + listRect: StubRect = { top: 0, bottom: 400 }, +) { + document.body.innerHTML = ''; + const list = document.createElement('div'); + for (const key of keys) { + const block = document.createElement('div'); + block.dataset.turnKey = key; + list.appendChild(block); + } + document.body.appendChild(list); + stubRect(list, listRect); + return { list, blocks: Array.from(list.children) as HTMLElement[] }; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('collectTurnBlocks', () => { + it('按文档顺序收集所有带回合身份的块', () => { + const { list } = buildList(['t1', 't2']); + expect(collectTurnBlocks(list)).toHaveLength(2); + }); +}); + +describe('readTopVisibleTurnAnchor', () => { + it('跳过完全滚出顶部的块,取第一条真正露出的块', () => { + const { list, blocks } = buildList(['t1', 't2', 't3']); + stubRect(blocks[0]!, { top: -120, bottom: -100 }); + stubRect(blocks[1]!, { top: -10, bottom: 30 }); + stubRect(blocks[2]!, { top: 40, bottom: 90 }); + + // 半条消息也算"正在读的位置":锚点必须是它,拿下一整条会把半条顶出视口。 + expect(readTopVisibleTurnAnchor(list)).toEqual({ + key: 't2', + index: 0, + offset: -10, + }); + }); + + it('同一个回合的第二个块露出时序号是 1', () => { + const { list, blocks } = buildList(['t1', 't2', 't2']); + stubRect(blocks[0]!, { top: -200, bottom: -150 }); + stubRect(blocks[1]!, { top: -140, bottom: -60 }); + stubRect(blocks[2]!, { top: -20, bottom: 40 }); + + expect(readTopVisibleTurnAnchor(list)).toEqual({ + key: 't2', + index: 1, + offset: -20, + }); + }); + + it('列表里没有块露出视口时没有锚点', () => { + const { list, blocks } = buildList(['t1']); + stubRect(blocks[0]!, { top: -50, bottom: -10 }); + expect(readTopVisibleTurnAnchor(list)).toBeNull(); + }); +}); + +describe('findTurnBlock', () => { + it('按回合 key 与块序号定位', () => { + const { list, blocks } = buildList(['t1', 't2', 't2']); + expect(findTurnBlock(list, 't2', 0)).toBe(blocks[1]); + expect(findTurnBlock(list, 't2', 1)).toBe(blocks[2]); + expect(findTurnBlock(list, 't2', 2)).toBeNull(); + }); +}); + +describe('restoreTurnAnchor', () => { + it('按块当前位置差改 scrollTop,让锚点回到原来的偏移', () => { + const { list, blocks } = buildList(['t1', 't2']); + // 更早历史插进来之后,原本贴在视口顶边的 t2 被顶到下方 90px。 + stubRect(blocks[1]!, { top: 90, bottom: 140 }); + list.scrollTop = 0; + const anchor: ConversationTurnAnchor = { key: 't2', index: 0, offset: -10 }; + + expect(restoreTurnAnchor(list, anchor)).toBe(true); + expect(list.scrollTop).toBe(100); + }); + + it('锚点对应的块已经不在了:返回 false,不动 scrollTop', () => { + const { list } = buildList(['t1']); + list.scrollTop = 30; + + expect( + restoreTurnAnchor(list, { key: 'missing', index: 0, offset: -10 }), + ).toBe(false); + expect(list.scrollTop).toBe(30); + }); +}); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.test.ts new file mode 100644 index 000000000..9351745da --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { + type ConversationHistoryLoadGate, + DIRECT_HISTORY_TOP_THRESHOLD, + DIRECT_SCROLL_BOTTOM_THRESHOLD, + distanceFromBottom, + isNearBottom, + isNearHistoryTop, + needsViewportFill, + scrollToBottomLabel, + shouldFillViewport, + shouldLoadEarlierOnScroll, + terminalContentSignature, +} from './conversationScrollPolicy'; + +const metrics = ( + scrollTop: number, + scrollHeight: number, + clientHeight: number, +) => ({ scrollTop, scrollHeight, clientHeight }); + +const openGate: ConversationHistoryLoadGate = { + historyHasMore: true, + historyLoading: false, + historyError: null, +}; + +describe('阈值', () => { + it('触顶阈值必须小于贴底阈值', () => { + // 相等时刚往下滚一点就会同时被判成「触顶」和「在底部」,历史加载与跟随最新互相打架。 + expect(DIRECT_HISTORY_TOP_THRESHOLD).toBeLessThan( + DIRECT_SCROLL_BOTTOM_THRESHOLD, + ); + }); +}); + +describe('isNearBottom', () => { + it('距底恰好等于阈值算在底部,超出一点就不算', () => { + expect(isNearBottom(metrics(0, 400 + 48, 400))).toBe(true); + expect(isNearBottom(metrics(0, 400 + 49, 400))).toBe(false); + }); + + it('内容比视口还短时按在底部处理', () => { + expect(distanceFromBottom(metrics(0, 120, 400))).toBe(0); + expect(isNearBottom(metrics(0, 120, 400))).toBe(true); + }); +}); + +describe('isNearHistoryTop', () => { + it('只在清单顶部阈值以内算触顶', () => { + const atTop = metrics(DIRECT_HISTORY_TOP_THRESHOLD, 900, 400); + const oneMorePx = metrics(DIRECT_HISTORY_TOP_THRESHOLD + 1, 900, 400); + expect(isNearHistoryTop(atTop)).toBe(true); + expect(isNearHistoryTop(oneMorePx)).toBe(false); + }); +}); + +describe('needsViewportFill', () => { + it('内容高度不超过视口高度就是还没铺满', () => { + expect(needsViewportFill(metrics(0, 400, 400))).toBe(true); + expect(needsViewportFill(metrics(0, 401, 400))).toBe(false); + }); +}); + +describe('自动加载的门', () => { + it('滚动触顶且门全开才加载', () => { + expect(shouldLoadEarlierOnScroll(metrics(0, 900, 400), openGate)).toBe( + true, + ); + expect(shouldLoadEarlierOnScroll(metrics(25, 900, 400), openGate)).toBe( + false, + ); + }); + + it('没有更早历史 / 正在加载 / 失败挂起,三种情况都不再自动触发', () => { + const atTop = metrics(0, 900, 400); + expect( + shouldLoadEarlierOnScroll(atTop, { + ...openGate, + historyHasMore: false, + }), + ).toBe(false); + expect( + shouldLoadEarlierOnScroll(atTop, { ...openGate, historyLoading: true }), + ).toBe(false); + expect( + shouldLoadEarlierOnScroll(atTop, { + ...openGate, + historyError: '读取失败', + }), + ).toBe(false); + }); + + it('填充视口与滚动触发共用同一道门', () => { + const short = metrics(0, 200, 400); + expect(shouldFillViewport(short, openGate)).toBe(true); + expect(shouldFillViewport(metrics(0, 900, 400), openGate)).toBe(false); + expect( + shouldFillViewport(short, { ...openGate, historyError: '读取失败' }), + ).toBe(false); + }); +}); + +describe('回到底部文案', () => { + it('有新回复才换文案', () => { + expect(scrollToBottomLabel(false)).toBe('回到底部'); + expect(scrollToBottomLabel(true)).toBe('有新回复 · 回到底部'); + }); +}); + +describe('terminalContentSignature', () => { + const turn = (key: string, users: number, finals: number) => ({ + key, + users: Array.from({ length: users }, (_, index) => index), + finals: Array.from({ length: finals }, (_, index) => index), + }); + + it('空列表没有指纹', () => { + expect(terminalContentSignature([])).toBe(''); + }); + + it('往前插更早历史不改变指纹', () => { + const latest = turn('t2', 1, 1); + expect(terminalContentSignature([latest])).toBe( + terminalContentSignature([turn('t1', 1, 1), latest]), + ); + }); + + it('最后一轮多出用户气泡或助手终态都算新内容', () => { + const base = turn('t2', 1, 1); + expect(terminalContentSignature([turn('t2', 1, 2)])).not.toBe( + terminalContentSignature([base]), + ); + expect(terminalContentSignature([turn('t2', 2, 1)])).not.toBe( + terminalContentSignature([base]), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts new file mode 100644 index 000000000..021fd7cc4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts @@ -0,0 +1,131 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + freezeToggleHead, + resolveToggleBody, + resolveToggleHead, +} from './conversationToggleReveal'; + +type StubRect = { top: number; bottom: number }; + +function stubRect(element: HTMLElement, rect: StubRect): void { + element.getBoundingClientRect = () => + ({ + top: rect.top, + bottom: rect.bottom, + height: rect.bottom - rect.top, + left: 0, + right: 0, + width: 0, + x: 0, + y: rect.top, + toJSON: () => ({}), + }) as DOMRect; +} + +/** + * 一块折叠区的两种形态: + * - `
`(回合的「执行过程」); + * - `[aria-expanded]` 按钮 + `aria-controls` 正文(工具组头、「思考过程」)。 + */ +function buildFixture() { + document.body.innerHTML = ` +
+
+ 执行过程 +
过程正文
+
+
+ +
工具正文
+
+
不是折叠头
+
`; + const list = document.getElementById('list') as HTMLDivElement; + stubRect(list, { top: 0, bottom: 400 }); + return { + list, + turnHead: document.getElementById('turn-head') as HTMLElement, + turnBody: document.getElementById('turn-body') as HTMLElement, + toolHead: document.getElementById('tool-head') as HTMLElement, + toolBody: document.getElementById('tool-body') as HTMLElement, + }; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('resolveToggleHead', () => { + it('summary 与 aria-expanded 两种折叠头都要认出来', () => { + buildFixture(); + const turnHead = document.getElementById('turn-head'); + expect(resolveToggleHead(turnHead)?.tagName).toBe('SUMMARY'); + expect(resolveToggleHead(document.getElementById('tool-hit'))?.id).toBe( + 'tool-head', + ); + expect(resolveToggleHead(document.getElementById('plain-hit'))).toBeNull(); + expect(resolveToggleHead(null)).toBeNull(); + }); +}); + +describe('resolveToggleBody', () => { + it('summary 取 details 里第一个非 summary 子元素', () => { + const { turnHead, turnBody } = buildFixture(); + expect(resolveToggleBody(turnHead)).toBe(turnBody); + }); + + it('按钮按 aria-controls 找正文,没有就返回 null', () => { + const { toolHead, toolBody } = buildFixture(); + expect(resolveToggleBody(toolHead)).toBe(toolBody); + const bare = document.createElement('button'); + bare.setAttribute('aria-expanded', 'true'); + expect(resolveToggleBody(bare)).toBeNull(); + }); +}); + +describe('freezeToggleHead', () => { + it('头部仍在视口里:按位移把头部拉回展开前的屏幕位置', () => { + const { list, turnHead } = buildFixture(); + stubRect(turnHead, { top: 10, bottom: 30 }); + list.scrollTop = 0; + + expect(freezeToggleHead(list, { head: turnHead, headTop: -40 })).toBe(true); + expect(list.scrollTop).toBe(50); + }); + + it('展开正文比视口还高:再把正文顶边对齐到视口顶边', () => { + const { list, turnHead, turnBody } = buildFixture(); + stubRect(turnHead, { top: 10, bottom: 30 }); + stubRect(turnBody, { top: 40, bottom: 600 }); + list.scrollTop = 0; + + expect(freezeToggleHead(list, { head: turnHead, headTop: -40 })).toBe(true); + // 头部冻结 50px 之后,正文仍比视口高且底部超出,再对齐正文顶边(+40)。 + expect(list.scrollTop).toBe(90); + }); + + it('头部已经不在视口里:不补偿,交给锚点还原', () => { + const { list, turnHead } = buildFixture(); + stubRect(turnHead, { top: 450, bottom: 480 }); + list.scrollTop = 20; + + expect(freezeToggleHead(list, { head: turnHead, headTop: 100 })).toBe( + false, + ); + expect(list.scrollTop).toBe(20); + }); + + it('头部已经卸载:不补偿', () => { + const { list, turnHead } = buildFixture(); + stubRect(turnHead, { top: 10, bottom: 30 }); + turnHead.remove(); + list.scrollTop = 0; + + expect(freezeToggleHead(list, { head: turnHead, headTop: 10 })).toBe(false); + expect(list.scrollTop).toBe(0); + }); +}); 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 new file mode 100644 index 000000000..4a069da20 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx @@ -0,0 +1,318 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectChatTurn } from '../../conversation/directTurnPresentation'; +import { useConversationScroll } from './useConversationScroll'; + +type ListMetrics = { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}; + +const PATCHED_GEOMETRY = [ + 'scrollTop', + 'scrollHeight', + 'clientHeight', + 'getBoundingClientRect', +] as const; + +/** + * jsdom 不做布局:几何量全部挂到 `HTMLDivElement.prototype` 上,用例自己给数字。 + * + * 挂在原型而不是实例上,是因为列表元素由 React 在渲染时创建,挂载期读几何的效果必须 + * 一开始就能读到这些值。`afterEach` 删掉这些自有属性即可落回 jsdom 的原实现。 + */ +function stubListMetrics(metrics: ListMetrics): void { + Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', { + configurable: true, + get: () => metrics.scrollHeight, + }); + Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', { + configurable: true, + get: () => metrics.clientHeight, + }); + Object.defineProperty(HTMLDivElement.prototype, 'scrollTop', { + configurable: true, + get: () => metrics.scrollTop, + set: (value: number) => { + metrics.scrollTop = value; + }, + }); + Object.defineProperty(HTMLDivElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + top: 0, + bottom: metrics.clientHeight, + height: metrics.clientHeight, + left: 0, + right: 0, + width: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect, + }); +} + +function Harness({ + turns, + historyHasMore, + historyLoading, + historyError, + turnInFlight = false, + onLoadEarlierHistory, +}: { + turns: DirectChatTurn[]; + historyHasMore: boolean; + historyLoading: boolean; + historyError: string | null; + turnInFlight?: boolean; + onLoadEarlierHistory: () => void; +}) { + const scroll = useConversationScroll({ + turns, + historyHasMore, + historyLoading, + historyError, + turnInFlight, + onLoadEarlierHistory, + }); + return ( + <> +
+ + {scroll.showScrollToBottom ? scroll.scrollToBottomLabel : ''} + + {scroll.showHistoryLoading ? : null} + + ); +} + +const turn = ( + key: string, + overrides: Partial = {}, +): DirectChatTurn => ({ + key, + users: [{ kind: 'user', key: `${key}-u`, text: '做一个跳跃动作', at: 10 }], + process: [], + finals: [{ kind: 'assistant', key: `${key}-f`, text: '改好了', at: 20 }], + state: 'finished', + startedAt: 10, + endedAt: 20, + ...overrides, +}); + +afterEach(() => { + cleanup(); + for (const key of PATCHED_GEOMETRY) { + Reflect.deleteProperty(HTMLDivElement.prototype, key); + } +}); + +describe('更早历史的自动加载', () => { + it('内容还没铺满视口:首帧之后继续加载,直到门关上', () => { + stubListMetrics({ scrollTop: 0, scrollHeight: 100, clientHeight: 400 }); + const load = vi.fn(); + render( + , + ); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('滚动触顶才加载:已经离顶就不再触发', () => { + const metrics: ListMetrics = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + stubListMetrics(metrics); + const load = vi.fn(); + const view = render( + , + ); + // 内容比视口高,填充视口的判据不成立。 + expect(load).not.toHaveBeenCalled(); + + metrics.scrollTop = 10; + fireEvent.scroll(view.getByTestId('list')); + expect(load).toHaveBeenCalledTimes(1); + + metrics.scrollTop = 400; + fireEvent.scroll(view.getByTestId('list')); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('失败挂起后不再自动加载:只有手动重试能解开', () => { + const metrics: ListMetrics = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + stubListMetrics(metrics); + const load = vi.fn(); + const view = render( + , + ); + metrics.scrollTop = 0; + fireEvent.scroll(view.getByTestId('list')); + expect(load).not.toHaveBeenCalled(); + }); +}); + +describe('回到底部胶囊', () => { + it('离开底部才出现,进入阈值即消失', () => { + const metrics: ListMetrics = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + stubListMetrics(metrics); + const view = render( + undefined} + />, + ); + const capsule = () => view.getByTestId('capsule').textContent; + // 初始按"在底部"处理:首帧不该凭空冒出一颗按钮。 + expect(capsule()).toBe(''); + + // 挂载时的贴底效果已经把 scrollTop 推到内容高度:先让用户真的往上滚。 + metrics.scrollTop = 0; + fireEvent.scroll(view.getByTestId('list')); + expect(capsule()).toBe('回到底部'); + + metrics.scrollTop = 1200 - 400 - 10; + fireEvent.scroll(view.getByTestId('list')); + expect(capsule()).toBe(''); + }); + + it('不跟随时来了新的终态内容:文案换成「有新回复 · 回到底部」', () => { + const metrics: ListMetrics = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + stubListMetrics(metrics); + const view = render( + undefined} + />, + ); + const capsule = () => view.getByTestId('capsule').textContent; + // 挂载时的贴底效果已经把 scrollTop 推到内容高度:先让用户真的往上滚。 + metrics.scrollTop = 0; + fireEvent.scroll(view.getByTestId('list')); + expect(capsule()).toBe('回到底部'); + + // 新一回合:用户气泡 + 助手终态都是新内容。 + view.rerender( + undefined} + />, + ); + expect(capsule()).toBe('有新回复 · 回到底部'); + }); + + it('往前插更早历史不算新回复:只把用户往上顶,不点亮提示', () => { + const metrics: ListMetrics = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + stubListMetrics(metrics); + const view = render( + undefined} + />, + ); + const capsule = () => view.getByTestId('capsule').textContent; + // 挂载时的贴底效果已经把 scrollTop 推到内容高度:先让用户真的往上滚。 + metrics.scrollTop = 0; + fireEvent.scroll(view.getByTestId('list')); + expect(capsule()).toBe('回到底部'); + + view.rerender( + undefined} + />, + ); + expect(capsule()).toBe('回到底部'); + }); +}); + +describe('加载行', () => { + it('延迟 150ms 才显示:本地瞬时读取不闪一下', () => { + vi.useFakeTimers(); + try { + stubListMetrics({ scrollTop: 0, scrollHeight: 1200, clientHeight: 400 }); + const view = render( + undefined} + />, + ); + expect(view.queryByTestId('loading')).toBeNull(); + + act(() => { + vi.advanceTimersByTime(149); + }); + expect(view.queryByTestId('loading')).toBeNull(); + + act(() => { + vi.advanceTimersByTime(2); + }); + expect(view.queryByTestId('loading')).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index 20a1ff525..bcb9b3135 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -356,9 +356,11 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 bodyIndex, ); - // 唯一滚动区仍是消息列表。 + // 唯一滚动区仍是消息列表;`[overflow-anchor:none]` 是滚动补偿契约的一部分 + // (见 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`), + // 原生 scroll anchoring 一旦重新打开就会和会话侧补偿互相抵消。 expect(conversationSource).toContain( - 'className="message-list project-chat-message-list"', + 'className="message-list project-chat-message-list [overflow-anchor:none]"', ); const formOpen = composerSource.indexOf('()} historyHasMore={false} + historyLoading={false} + historyError={null} turnInFlight activeTurnStartedAt={STARTED_AT} onLoadEarlierHistory={() => undefined} - onScroll={() => undefined} + onRetryEarlierHistory={() => undefined} />, ); const elapsed = () => view.getByText(/^已耗时 /u).textContent; @@ -59,12 +60,13 @@ test('没有运行中的回合时不订阅时钟', () => { render( ()} historyHasMore={false} + historyLoading={false} + historyError={null} turnInFlight={false} activeTurnStartedAt={0} onLoadEarlierHistory={() => undefined} - onScroll={() => undefined} + onRetryEarlierHistory={() => undefined} />, ); expect(spy).not.toHaveBeenCalled(); diff --git a/vitest.config.ts b/vitest.config.ts index 8f3abe2bb..2d66a04ff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -162,6 +162,9 @@ export default defineConfig({ 'apps/ai-game-creator-shell/tests/**/*.test.ts', 'apps/ai-game-creator-shell/tests/**/*.test.tsx', 'apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.test.tsx', + // DirectProject 会话滚动的判据与 hook 与实现同目录:改滚动契约时用例就在旁边。 + 'apps/ai-game-creator-shell/src/view/project-development/chat/**/*.test.ts', + 'apps/ai-game-creator-shell/src/view/project-development/chat/**/*.test.tsx', 'miniprogram/**/*.test.js', 'scripts/**/*.test.ts', 'packages/shared/src/contracts/*.test.ts',