测试:覆盖更早历史自动加载、前插锚点与回到底部胶囊

- 新增与实现同目录的 5 组用例:阈值与加载门、锚点读取/还原、折叠头冻结与正文对齐、hook 状态机、组件层按钮移除与重试行
- 根 vitest.config.ts 补一条 include,同目录用例才进得了门禁
- 既有断言同步:消息列表类名加上 overflow-anchor 工具类,DirectProjectConversation 用例改用新的加载状态入参

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 17:54:10 +08:00
parent 0aa9dad3c7
commit 65b90ed43e
8 changed files with 816 additions and 7 deletions
@@ -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(
<DirectProjectConversation
turns={overrides.turns ?? []}
historyHasMore={overrides.historyHasMore ?? false}
historyLoading={overrides.historyLoading ?? false}
historyError={overrides.historyError ?? null}
turnInFlight={false}
activeTurnStartedAt={0}
onLoadEarlierHistory={overrides.onLoadEarlierHistory ?? (() => 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);
});
});
@@ -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);
});
});
@@ -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]),
);
});
});
@@ -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;
}
/**
* 一块折叠区的两种形态:
* - `<details>`(回合的「执行过程」);
* - `[aria-expanded]` 按钮 + `aria-controls` 正文(工具组头、「思考过程」)。
*/
function buildFixture() {
document.body.innerHTML = `
<div id="list">
<details id="turn-fold">
<summary id="turn-head">执行过程</summary>
<div id="turn-body">过程正文</div>
</details>
<section>
<button id="tool-head" type="button" aria-expanded="true" aria-controls="tool-body">
<span id="tool-hit">工具组</span>
</button>
<div id="tool-body">工具正文</div>
</section>
<div id="plain"><span id="plain-hit">不是折叠头</span></div>
</div>`;
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);
});
});
@@ -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 (
<>
<div
ref={scroll.listRef}
data-testid="list"
onScroll={scroll.onScroll}
onClickCapture={scroll.onToggleCapture}
/>
<span data-testid="capsule">
{scroll.showScrollToBottom ? scroll.scrollToBottomLabel : ''}
</span>
{scroll.showHistoryLoading ? <span data-testid="loading" /> : null}
</>
);
}
const turn = (
key: string,
overrides: Partial<DirectChatTurn> = {},
): 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(
<Harness
turns={[]}
historyHasMore
historyLoading={false}
historyError={null}
onLoadEarlierHistory={load}
/>,
);
expect(load).toHaveBeenCalledTimes(1);
});
it('滚动触顶才加载:已经离顶就不再触发', () => {
const metrics: ListMetrics = {
scrollTop: 0,
scrollHeight: 1200,
clientHeight: 400,
};
stubListMetrics(metrics);
const load = vi.fn();
const view = render(
<Harness
turns={[]}
historyHasMore
historyLoading={false}
historyError={null}
onLoadEarlierHistory={load}
/>,
);
// 内容比视口高,填充视口的判据不成立。
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(
<Harness
turns={[]}
historyHasMore
historyLoading={false}
historyError="读取更早的对话历史失败"
onLoadEarlierHistory={load}
/>,
);
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(
<Harness
turns={[]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => 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(
<Harness
turns={[turn('t1')]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
const capsule = () => view.getByTestId('capsule').textContent;
// 挂载时的贴底效果已经把 scrollTop 推到内容高度:先让用户真的往上滚。
metrics.scrollTop = 0;
fireEvent.scroll(view.getByTestId('list'));
expect(capsule()).toBe('回到底部');
// 新一回合:用户气泡 + 助手终态都是新内容。
view.rerender(
<Harness
turns={[turn('t1'), turn('t2')]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
expect(capsule()).toBe('有新回复 · 回到底部');
});
it('往前插更早历史不算新回复:只把用户往上顶,不点亮提示', () => {
const metrics: ListMetrics = {
scrollTop: 0,
scrollHeight: 1200,
clientHeight: 400,
};
stubListMetrics(metrics);
const view = render(
<Harness
turns={[turn('t1'), turn('t2')]}
historyHasMore
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
const capsule = () => view.getByTestId('capsule').textContent;
// 挂载时的贴底效果已经把 scrollTop 推到内容高度:先让用户真的往上滚。
metrics.scrollTop = 0;
fireEvent.scroll(view.getByTestId('list'));
expect(capsule()).toBe('回到底部');
view.rerender(
<Harness
turns={[turn('t0'), turn('t1'), turn('t2')]}
historyHasMore
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
expect(capsule()).toBe('回到底部');
});
});
describe('加载行', () => {
it('延迟 150ms 才显示:本地瞬时读取不闪一下', () => {
vi.useFakeTimers();
try {
stubListMetrics({ scrollTop: 0, scrollHeight: 1200, clientHeight: 400 });
const view = render(
<Harness
turns={[]}
historyHasMore
historyLoading
historyError={null}
onLoadEarlierHistory={() => 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();
}
});
});
@@ -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('<form');
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react';
import { act, createRef } from 'react';
import { act } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import { DirectProjectConversation } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation';
@@ -25,12 +25,13 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以
const view = render(
<DirectProjectConversation
turns={[]}
messagesRef={createRef<HTMLDivElement>()}
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(
<DirectProjectConversation
turns={[]}
messagesRef={createRef<HTMLDivElement>()}
historyHasMore={false}
historyLoading={false}
historyError={null}
turnInFlight={false}
activeTurnStartedAt={0}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}
onRetryEarlierHistory={() => undefined}
/>,
);
expect(spy).not.toHaveBeenCalled();
+3
View File
@@ -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',