380 lines
18 KiB
TypeScript
380 lines
18 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import { noticeTone } from "../src/lib/chat-notice.ts";
|
||
import {
|
||
applyFollowBottom,
|
||
applyTurnSpacer,
|
||
clearTurnSpacer,
|
||
conversationAnchorThreshold,
|
||
latestContentBelowFold,
|
||
nextAnchorState,
|
||
pinTurnScrollTop,
|
||
resolveTurnHead,
|
||
turnTailOverflow,
|
||
} from "../src/hooks/use-conversation-scroll-anchor.ts";
|
||
import { homeSurface } from "./home-surface.ts";
|
||
|
||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
|
||
const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
|
||
const globalsSource = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
|
||
function sourceBetween(source: string, startMarker: string, endMarker: string) {
|
||
const start = source.indexOf(startMarker);
|
||
const end = source.indexOf(endMarker, start);
|
||
assert.notEqual(start, -1);
|
||
assert.notEqual(end, -1);
|
||
return source.slice(start, end);
|
||
}
|
||
|
||
test("routes composer notices to the user instead of discarding them", () => {
|
||
// Given: the page no longer keeps the notice in write-only state.
|
||
assert.doesNotMatch(pageSource, /const \[, setComposerNotice\] = useState/);
|
||
assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
|
||
|
||
// Then: every notice reaches the mounted sonner toaster.
|
||
assert.match(noticeSource, /import \{ toast \} from "sonner"/);
|
||
assert.match(noticeSource, /toast\.success\(message, options\)/);
|
||
assert.match(noticeSource, /toast\.error\(message, options\)/);
|
||
assert.match(noticeSource, /toast\(message, options\)/);
|
||
});
|
||
|
||
test("clearing a notice dismisses instead of showing an empty toast", () => {
|
||
const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message && !action) return;");
|
||
|
||
assert.match(clearBranch, /toast\.dismiss\(chatNoticeToastId\)/);
|
||
assert.doesNotMatch(clearBranch, /toast\(|toast\.success|toast\.error/);
|
||
assert.match(pageSource, /setComposerNotice\(""\)/);
|
||
});
|
||
|
||
test("keeps the recovery poll from stacking repeated notices", () => {
|
||
// Given: the recovery loop repeats the same message every 1750ms.
|
||
assert.match(pageSource, /timer = window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
|
||
|
||
// Then: a stable toast id plus last-message dedupe replaces instead of accumulating.
|
||
assert.match(noticeSource, /export const chatNoticeToastId = "chat-notice"/);
|
||
assert.match(noticeSource, /if \(lastNotice === message && !action\) return;/);
|
||
});
|
||
|
||
test("assigns notice severity by message intent", () => {
|
||
assert.equal(noticeTone("网络已断开,回答仍在后台生成;联网后会自动恢复。"), "info");
|
||
assert.equal(noticeTone("回答仍在后台生成,正在自动恢复。"), "info");
|
||
assert.equal(noticeTone("正在确认本次咨询请求是否已开始…"), "info");
|
||
assert.equal(noticeTone("此前选择的模型已下线,已切换为默认模型。"), "info");
|
||
assert.equal(noticeTone("回答已取消;问题仍保留在聊天记录中,可重新发送。"), "info");
|
||
assert.equal(noticeTone("回答已恢复。"), "success");
|
||
assert.equal(noticeTone("已归档,可在左侧归档中恢复。"), "success");
|
||
assert.equal(noticeTone("已停止回答,现有内容已保留,本次点数已退回。"), "success");
|
||
assert.equal(noticeTone("这段对话已写满,开个新对话继续吧"), "error");
|
||
assert.equal(noticeTone("该对话不存在或已被删除"), "error");
|
||
assert.equal(noticeTone("删除失败:网络异常"), "error");
|
||
assert.equal(noticeTone("重命名同步失败"), "error");
|
||
assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error");
|
||
assert.equal(noticeTone("后台未找到本次咨询请求,已停止恢复,请重新发送。"), "error");
|
||
assert.equal(noticeTone("未能存入历史"), "error");
|
||
assert.equal(noticeTone("保存失败,请重试"), "error");
|
||
});
|
||
|
||
test("anchors the streaming scroll instead of following every token", () => {
|
||
// Former lock: a page-level effect keyed on `activeStreamingText` that called
|
||
// `container.scrollTo` after an `anchored` guard. That effect ran once per token and added
|
||
// a second smooth scroll on settle; the follow now lives in the hook, driven by a
|
||
// ResizeObserver over the scroller's children, one frame per change (BUG-478).
|
||
// 原值: follow() 在 !anchored 时直接 return
|
||
// 新值: follow() 仅在 anchored 时写 scrollTop = scrollHeight,未钉住时只刷新 spacer
|
||
// 原因: BUG-930 流式期间视口钉在本轮开头,留白变量仍要随高度更新
|
||
assert.doesNotMatch(pageSource, /container\.scrollTo\(/);
|
||
assert.match(anchorSource, /if \(anchoredRef\.current\) \{\s*element\.scrollTop = element\.scrollHeight;/);
|
||
assert.match(anchorSource, /applyTurnSpacer\(element,/);
|
||
assert.match(anchorSource, /new ResizeObserver\(requestFollow\)/);
|
||
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(follow\)/);
|
||
assert.match(anchorSource, /observe\(element, \{ childList: true \}\)/);
|
||
});
|
||
|
||
test("scrolls to the newest turn on intentional jumps", () => {
|
||
// Given: switching sessions resets the anchor through the hook reset key.
|
||
assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,\n\s*activeSessionId,\n\s*\)/);
|
||
assert.match(anchorSource, /const anchored = anchor\.key === resetKey \? anchor\.anchored : true/);
|
||
|
||
// 原值: send() 调 anchorToLatest(),乐观入列后贴底
|
||
// 新值: send() 调 pinLatestTurn(),下一帧把本轮开头钉到顶部
|
||
// 原因: BUG-930
|
||
const sendBlock = sourceBetween(homeSurface, " updateSession(sessionId, () => userSession);", " setDraft(\"\");");
|
||
assert.match(sendBlock, /conversationAnchor\.pinLatestTurn\(\)/);
|
||
assert.doesNotMatch(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
|
||
});
|
||
|
||
test("offers an accessible jump-to-latest control while reading history", () => {
|
||
// Former locks pinned the inline Tailwind button in page.tsx (`min-h-11`, `focus-visible:ring-3`,
|
||
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
|
||
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
|
||
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
|
||
// 原值: jumpToLatestVisible 看 !conversationAnchor.anchored
|
||
// 新值: 看 conversationAnchor.latestBelowFold(底部距离 > 96px)
|
||
// 原因: BUG-930 回答长出视口时即使读者没主动上滑也要出现「跳到最新」
|
||
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
|
||
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
|
||
const buttonRule = globalsSource.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
|
||
|
||
assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& conversationAnchor\.latestBelowFold/);
|
||
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||
assert.match(jumpSource, /type="button"/);
|
||
assert.match(jumpSource, /aria-label="跳到最新"/);
|
||
assert.match(globalsSource, /\.jump-to-latest__button:focus-visible \{[^}]*outline: 3px solid/);
|
||
assert.match(buttonRule, /min-height: 44px/);
|
||
assert.match(buttonRule, /min-width: 44px/);
|
||
assert.match(buttonRule, /box-shadow: var\(--shadow-elevated\)/);
|
||
});
|
||
|
||
test("rests the jump-to-latest control on the composer instead of the padded scroller", () => {
|
||
// Given: the scroll container reserves composer-sized padding, which clamps any sticky child
|
||
// of it that far above the composer, on top of the transcript the reader is still reading.
|
||
assert.match(globalsSource, /\.conversation \{[^}]*padding-bottom: var\(--composer-reserve\)/);
|
||
assert.match(globalsSource, /\.composer-wrap \{[^}]*position: sticky/);
|
||
|
||
// Then: the control hangs off the composer's own top edge, whatever that reserve is.
|
||
const composerWrap = sourceBetween(pageSource, "className={`composer-wrap ", "<ChatComposer");
|
||
assert.match(composerWrap, /<JumpToLatestButton/);
|
||
const wrapRule = globalsSource.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
|
||
assert.match(wrapRule, /position: absolute/);
|
||
assert.match(wrapRule, /bottom: 100%/);
|
||
assert.doesNotMatch(wrapRule, /sticky/);
|
||
});
|
||
|
||
test("keeps the scroll listener passive and reduced-motion aware", () => {
|
||
assert.match(anchorSource, /addEventListener\("scroll", onScroll, \{ passive: true \}\)/);
|
||
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(measure\)/);
|
||
assert.match(anchorSource, /window\.matchMedia\("\(prefers-reduced-motion: reduce\)"\)\.matches/);
|
||
assert.match(anchorSource, /behavior: reduceMotion \? "auto" : "smooth"/);
|
||
});
|
||
|
||
test("re-anchors once the reader returns to the newest turn", () => {
|
||
// Given: the reader is following the stream and scrolls up mid-answer.
|
||
assert.equal(nextAnchorState(true, 0, false), true);
|
||
assert.equal(nextAnchorState(true, 600, true), false);
|
||
|
||
// Then: growing content alone never re-anchors, but scrolling back does.
|
||
assert.equal(nextAnchorState(false, 600, false), false);
|
||
assert.equal(nextAnchorState(false, 40, false), true);
|
||
});
|
||
|
||
test("pins a new turn at the head instead of following streamed growth", () => {
|
||
const user = fakeRow("message-user", 16, 48);
|
||
const assistant = fakeRow("message-assistant", 80, 200);
|
||
const scroller = fakeScroller([user, assistant], 400);
|
||
const pinned = pinTurnScrollTop(asElement(scroller), asElement(user), 16);
|
||
scroller.scrollTop = pinned;
|
||
const start = scroller.scrollTop;
|
||
for (let i = 0; i < 5; i += 1) {
|
||
assistant.offsetHeight += 80;
|
||
scroller.scrollHeight += 80;
|
||
applyFollowBottom(scroller, false);
|
||
}
|
||
assert.equal(scroller.scrollTop, start);
|
||
assert.equal(start, user.offsetTop - 16);
|
||
|
||
applyFollowBottom(scroller, true);
|
||
assert.equal(scroller.scrollTop, scroller.scrollHeight);
|
||
});
|
||
|
||
test("latestBelowFold tracks overflow past the fold and clears at the bottom", () => {
|
||
const user = fakeRow("message-user", 16, 48);
|
||
const assistant = fakeRow("message-assistant", 80, 200);
|
||
const scroller = fakeScroller([user, assistant], 400);
|
||
scroller.scrollTop = pinTurnScrollTop(asElement(scroller), asElement(user), 16);
|
||
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), false);
|
||
|
||
assistant.offsetHeight = 1200;
|
||
scroller.scrollHeight = assistant.offsetTop + assistant.offsetHeight + 24;
|
||
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), true);
|
||
assert.ok(turnTailOverflow(asElement(scroller)) > conversationAnchorThreshold);
|
||
|
||
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
|
||
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), false);
|
||
});
|
||
|
||
test("resolveTurnHead prefers the user row unless this turn has no user", () => {
|
||
const typedUser = fakeRow("message-user", 8, 40);
|
||
const typedAssistant = fakeRow("message-assistant", 64, 80);
|
||
const typed = fakeScroller([typedUser, typedAssistant], 400);
|
||
assert.equal(resolveTurnHead(asElement(typed)), asElement(typedUser));
|
||
|
||
const priorUser = fakeRow("message-user", 8, 40);
|
||
const priorAssistant = fakeRow("message-assistant", 64, 80);
|
||
const choiceAssistant = fakeRow("message-assistant", 160, 80);
|
||
const choice = fakeScroller([priorUser, priorAssistant, choiceAssistant], 400);
|
||
assert.equal(resolveTurnHead(asElement(choice)), asElement(choiceAssistant));
|
||
|
||
const opening = fakeRow("message-assistant", 0, 80);
|
||
const openingScroller = fakeScroller([opening], 400);
|
||
assert.equal(resolveTurnHead(asElement(openingScroller)), asElement(opening));
|
||
});
|
||
|
||
test("turn spacer uses conversation viewport variables and is not sticky", () => {
|
||
assert.match(
|
||
globalsSource,
|
||
/\.conversation \.message-list > :last-child \.message-assistant \{[^}]*min-height: calc\(var\(--conversation-viewport, 0px\) - var\(--latest-turn-head-height, 0px\)\)/,
|
||
);
|
||
const spacerRule = sourceBetween(
|
||
globalsSource,
|
||
".conversation .message-list > :last-child .message-assistant {",
|
||
"}",
|
||
);
|
||
assert.doesNotMatch(spacerRule, /sticky/);
|
||
assert.doesNotMatch(anchorSource, /position:\s*sticky/);
|
||
|
||
const scroller = fakeScroller([], 400);
|
||
const head = fakeRow("message-user", 16, 48);
|
||
applyTurnSpacer(asElement(scroller), asElement(head));
|
||
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "400px");
|
||
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "48px");
|
||
clearTurnSpacer(asElement(scroller));
|
||
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "");
|
||
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "");
|
||
});
|
||
|
||
test("a turn with no user row pins the new assistant and uses a full-viewport spacer", () => {
|
||
const priorUser = fakeRow("message-user", 8, 40);
|
||
const priorAssistant = fakeRow("message-assistant", 56, 80);
|
||
const laterUser = fakeRow("message-user", 152, 40);
|
||
const laterAssistant = fakeRow("message-assistant", 200, 80);
|
||
const incoming = fakeRow("message-assistant", 480, 80);
|
||
const scroller = fakeScroller([priorUser, priorAssistant, laterUser, laterAssistant, incoming], 600);
|
||
const head = resolveTurnHead(asElement(scroller));
|
||
assert.equal(head, asElement(incoming));
|
||
applyTurnSpacer(asElement(scroller), head);
|
||
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "0px");
|
||
scroller.scrollTop = pinTurnScrollTop(asElement(scroller), asElement(incoming), 16);
|
||
assert.equal(scroller.scrollTop, incoming.offsetTop - 16);
|
||
const start = scroller.scrollTop;
|
||
for (let i = 0; i < 3; i += 1) {
|
||
incoming.offsetHeight += 80;
|
||
scroller.scrollHeight += 80;
|
||
applyFollowBottom(scroller, false);
|
||
}
|
||
assert.equal(scroller.scrollTop, start);
|
||
});
|
||
|
||
test("idle history does not write a spacer when the reader has only scrolled up", () => {
|
||
// 原值: follow() 在 !anchored 时无条件 applyTurnSpacer
|
||
// 新值: 只在 pinnedHeadRef 非空时写留白
|
||
// 原因: BUG-932 闲置会话上滑后子元素尺寸变化不应把最后一条助手行撑开
|
||
assert.match(
|
||
anchorSource,
|
||
/if \(anchoredRef\.current\) \{\s*element\.scrollTop = element\.scrollHeight;\s*\} else if \(pinnedHeadRef\.current\) \{\s*applyTurnSpacer\(element, pinnedHeadRef\.current\);/,
|
||
);
|
||
assert.match(anchorSource, /pinnedHeadRef\.current = null;\s*if \(element\) \{\s*clearTurnSpacer\(element\);/);
|
||
const user = fakeRow("message-user", 8, 40);
|
||
const assistant = fakeRow("message-assistant", 56, 900);
|
||
const scroller = fakeScroller([user, assistant], 600);
|
||
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
|
||
scroller.scrollTop = Math.max(0, scroller.scrollTop - 400);
|
||
const start = scroller.scrollTop;
|
||
assistant.offsetHeight += 30;
|
||
scroller.scrollHeight += 30;
|
||
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "");
|
||
assert.equal(Math.abs(scroller.scrollTop - start) <= 1, true);
|
||
});
|
||
|
||
test("consultation send and rectification new turns call pinLatestTurn", () => {
|
||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||
const pins = chat.match(/conversationAnchor\.pinLatestTurn\(\)/g) ?? [];
|
||
assert.ok(pins.length >= 3, `expected three pinLatestTurn calls, got ${pins.length}`);
|
||
assert.doesNotMatch(chat, /conversation\.current\?\.scrollTo|element\.scrollTo/);
|
||
assert.match(chat, /conversationAnchor\.latestBelowFold/);
|
||
assert.match(anchorSource, /window\.requestAnimationFrame\(run\)/);
|
||
});
|
||
|
||
type FakeRow = {
|
||
className: string;
|
||
offsetTop: number;
|
||
offsetHeight: number;
|
||
order: number;
|
||
compareDocumentPosition: (other: Node) => number;
|
||
getBoundingClientRect: () => DOMRect;
|
||
};
|
||
|
||
type FakeScroller = {
|
||
rows: FakeRow[];
|
||
scrollTop: number;
|
||
clientHeight: number;
|
||
scrollHeight: number;
|
||
children: unknown[];
|
||
style: {
|
||
setProperty: (name: string, value: string) => void;
|
||
getPropertyValue: (name: string) => string;
|
||
removeProperty: (name: string) => void;
|
||
};
|
||
querySelectorAll: (selector: string) => FakeRow[];
|
||
querySelector: (selector: string) => FakeRow | null;
|
||
getBoundingClientRect: () => DOMRect;
|
||
};
|
||
|
||
function asElement(value: FakeRow | FakeScroller): HTMLElement {
|
||
return value as unknown as HTMLElement;
|
||
}
|
||
|
||
function fakeRow(className: string, offsetTop: number, offsetHeight: number): FakeRow {
|
||
const row = {
|
||
className,
|
||
offsetTop,
|
||
offsetHeight,
|
||
order: 0,
|
||
compareDocumentPosition(other: Node) {
|
||
const theirOrder = (other as unknown as FakeRow).order ?? 0;
|
||
if (this.order < theirOrder) return 4;
|
||
if (this.order > theirOrder) return 2;
|
||
return 0;
|
||
},
|
||
getBoundingClientRect() {
|
||
return { top: this.offsetTop, left: 0, right: 0, bottom: this.offsetTop + this.offsetHeight, width: 0, height: this.offsetHeight, x: 0, y: this.offsetTop, toJSON() { return this; } };
|
||
},
|
||
};
|
||
return row;
|
||
}
|
||
|
||
function fakeScroller(rows: FakeRow[], clientHeight: number): FakeScroller {
|
||
rows.forEach((row, index) => {
|
||
row.order = index;
|
||
});
|
||
const last = rows.at(-1);
|
||
const styleValues = new Map<string, string>();
|
||
const scroller = {
|
||
rows,
|
||
scrollTop: 0,
|
||
clientHeight,
|
||
scrollHeight: last ? last.offsetTop + last.offsetHeight + 24 : clientHeight,
|
||
children: [],
|
||
style: {
|
||
setProperty(name: string, value: string) {
|
||
styleValues.set(name, value);
|
||
},
|
||
getPropertyValue(name: string) {
|
||
return styleValues.get(name) ?? "";
|
||
},
|
||
removeProperty(name: string) {
|
||
styleValues.delete(name);
|
||
},
|
||
},
|
||
querySelectorAll(selector: string) {
|
||
const token = selector.replace(".", "");
|
||
return rows.filter((row) => row.className === token);
|
||
},
|
||
querySelector(selector: string) {
|
||
if (selector.includes(":last-child .message-assistant") || selector.includes(".message-assistant:last-of-type")) {
|
||
return rows.filter((row) => row.className === "message-assistant").at(-1) ?? null;
|
||
}
|
||
if (selector.includes(".message-user:last-of-type")) {
|
||
return rows.filter((row) => row.className === "message-user").at(-1) ?? null;
|
||
}
|
||
return null;
|
||
},
|
||
getBoundingClientRect() {
|
||
return { top: 0, left: 0, right: 0, bottom: clientHeight, width: 0, height: clientHeight, x: 0, y: 0, toJSON() { return this; } };
|
||
},
|
||
};
|
||
return scroller;
|
||
}
|