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 { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts"; const pageSource = readFileSync(new URL("../src/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, \{ id: chatNoticeToastId \}\)/); assert.match(noticeSource, /toast\.error\(message, \{ id: chatNoticeToastId \}\)/); assert.match(noticeSource, /toast\(message, \{ id: chatNoticeToastId \}\)/); }); test("clearing a notice dismisses instead of showing an empty toast", () => { const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message) 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\) 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"); }); test("anchors the streaming scroll instead of following every token", () => { const autoScrollEffect = sourceBetween( pageSource, "useEffect(() => {\n if (starterHomeVisible) return;", "profileComplete, starterHomeVisible]);", ); // Then: streamed tokens only move the viewport while the reader stays anchored. assert.match(autoScrollEffect, /if \(!conversationAnchor\.anchored\) return/); const scrollGuard = autoScrollEffect.indexOf("if (!conversationAnchor.anchored) return"); const scrollCall = autoScrollEffect.indexOf("container.scrollTo("); assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall); }); 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/); // And: sending a question re-anchors before the optimistic turn renders. const sendBlock = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", " setDraft(\"\");"); assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/); }); test("offers an accessible jump-to-latest control while reading history", () => { const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ""); assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/); assert.match(jumpControl, /type="button"/); assert.match(jumpControl, /aria-label="跳到最新"/); assert.match(jumpControl, /focus-visible:ring-3/); assert.match(jumpControl, /min-h-11/); assert.match(jumpControl, /min-w-11/); assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/); }); 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 ", ""); assert.match(jumpControl, /absolute inset-x-0 bottom-full/); assert.doesNotMatch(jumpControl, /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); });