Files
Jyotisha/frontend/tests/chat-notice-and-scroll-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 3a66c39f48 refactor(rectification): split the chat component into hooks, parameter functions and blocks
Move-only (TASK-rectification-code-split-20260926 T1/T4). No behavior, API,
copy, DB, Skill or scoring change.

- rectification-agentic-chat.tsx: 2043 -> 737 lines; component body
  1625 -> 630; useState 35 -> 12, useRef 16 -> 9, effects 9 -> 5,
  useCallback 12 -> 6.
- Snapshot sync -> hooks/use-rectification-case-snapshot.ts; board layout
  and live label clock -> two small hooks.
- send / submitStructuredChoice / acceptCandidate bodies ->
  lib/rectification-chat-{turn,choice,accept}-run.ts parameter functions
  (bodies verbatim, deps destructured to the same names); copy/regenerate,
  question repair, pure transcript and snapshot helpers and the per-render
  view derivation -> lib/rectification-chat-*.ts (no React hooks).
- Question-gap blocks and the read-only range line ->
  components/rectification-question-gap-notices.tsx.
- Dependency arrays kept exactly as before (lint warnings +3, listed in
  PROGRESS); no dep added to appease the linter.
- Tests: whole-source contracts read tests/rectification-chat-surface.ts
  (container + split files, like home-surface.ts); slices of moved code now
  call the extracted functions or render the extracted block, each with a
  原值/新值/原因 comment. New tests/rectification-growth-contract.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:23:52 +08:00

407 lines
21 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";
import { rectificationChatSurface } from "./rectification-chat-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.
const recoveryPoll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8");
// 原值: 1750ms 轮询写在 page.tsx。
// 新值: 轮询在 consultation-recovery-poll.ts,page.tsx 仍调用它。
// 原因: 首页拆分搬走了恢复轮询,间隔没变。
assert.match(pageSource, /runConsultationRecoveryPoll/);
assert.match(recoveryPoll, /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", () => {
// 原值:选择器 `.conversation .message-list > :last-child .message-assistant`(留白加在助手行上)。
// 新值:`.conversation .message-list > :last-child:has(.message-assistant)`(留白加在整轮外层,含 .message-actions / 追问建议 / 交付卡)。
// 原因:BUG-1042——助手行的 min-height 把其后的点赞 / 踩 / 复制 / 重试一排顶到留白下面;计算式与变量口径不变(BUG-930 钉顶保持)。
assert.match(
globalsSource,
/\.conversation \.message-list > :last-child:has\(\.message-assistant\) \{[^}]*min-height: calc\(var\(--conversation-viewport, 0px\) - var\(--latest-turn-head-height, 0px\)\)/,
);
const spacerRule = sourceBetween(
globalsSource,
".conversation .message-list > :last-child:has(.message-assistant) {",
"}",
);
assert.doesNotMatch(spacerRule, /sticky/);
// The rectification wrap is a grid: without this its rows stretch into the spacer.
assert.match(spacerRule, /align-content:\s*start/);
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("the turn spacer trails the actions row instead of sitting between text and actions (BUG-1042)", () => {
// No rule may put the viewport spacer on the assistant row itself.
assert.doesNotMatch(globalsSource, /\.message-assistant\s*\{[^}]*--conversation-viewport/);
// Both surfaces render the actions inside the same last-child entry, after the row.
const transcript = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const entry = sourceBetween(transcript, '<div className="message-entry">', "</div>");
assert.ok(entry.indexOf("<ChatMessageRow") < entry.indexOf("<ChatMessageActions"));
assert.ok(entry.indexOf("<ChatMessageActions") < entry.indexOf("<ConversationFollowUps"));
const rectEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.ok(rectEntry.indexOf("<ChatMessageRow") < rectEntry.indexOf("<ChatMessageActions"));
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const rectChat = rectificationChatSurface;
assert.match(rectChat, /className="rectification-message-wrap rectification-message-entry"\s*>\s*<RectificationMessageEntry/);
});
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", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
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;
}