测试:覆盖更早历史自动加载、前插锚点与回到底部胶囊
- 新增与实现同目录的 5 组用例:阈值与加载门、锚点读取/还原、折叠头冻结与正文对齐、hook 状态机、组件层按钮移除与重试行 - 根 vitest.config.ts 补一条 include,同目录用例才进得了门禁 - 既有断言同步:消息列表类名加上 overflow-anchor 工具类,DirectProjectConversation 用例改用新的加载状态入参 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
+92
@@ -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);
|
||||
});
|
||||
});
|
||||
+122
@@ -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);
|
||||
});
|
||||
});
|
||||
+139
@@ -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]),
|
||||
);
|
||||
});
|
||||
});
|
||||
+131
@@ -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);
|
||||
});
|
||||
});
|
||||
+318
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user