Files
Jyotisha/frontend/tests/chat-notice-and-scroll-contract.test.ts
T
jesse-ux cc1a89802e
Independent Staging Quality Gate / validate (push) Failing after 6m27s
Independent Staging Quality Gate / publish (push) Skipped
fix(web): 无用户行钉顶、闲置不上留白,并修会话列表 tsc 两错
2026-09-17 22:25:53 +08:00

380 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}