Files
Jyotisha/frontend/tests/chat-composer-queue.test.ts
T
Jesse_ChenandClaude Opus 5 317e9f1886
Independent Staging Quality Gate / validate (push) Successful in 10m24s
Independent Staging Quality Gate / publish (push) Successful in 19m9s
fix(rectification): 顶栏被 focus 滚出视野——面板改 clip,面板内 focus 一律 preventScroll
产品实测:生时校正答了三题后 46px 顶栏消失。devtools 取证——栅格是对的
(rows "46px 1297px"、panel y=0),但 header 自己的 rect 在 **y=-88**:
面板被程序化滚动了 88px。

根因:`overflow: hidden` 仍然是滚动容器,它只是去掉了滚动条。
BirthTimeChoiceQuestion 在每答完一题后 focus 新题的第一个选项,浏览器
为把它带进视野会滚动所有可滚动祖先,`.chat-panel` 就是其中之一——
而用户没有滚动条可以滚回来,顶栏于是永久消失。

两处都修:
- 病因:面板内 7 处程序化 focus 一律加 { preventScroll: true }。
  消息区有自己的 useConversationScrollAnchor,本来就不需要浏览器代劳。
  账户弹窗的 closeButton / returnTarget 不在此列——那是对话框焦点管理。
  同一教训 use-billing-panel.ts 已经吃过一次(那里早写了 preventScroll)。
- 结构:.chat-panel 与 .chat-app 从 overflow:hidden 改成 overflow:clip。
  clip 根本不创建滚动容器,此后任何 focus / scrollIntoView 都无法位移它。

新增 tests/chat-panel-scroll-guard.test.ts:锁住两个容器必须是 clip、
面板内不得有裸 focus(),并遍历 rectification/birth-time/chat- 全部组件,
新组件再写裸 focus 会直接打红。

测试 3372(+3),fail 仍 31 且与基线逐条一致;四个路由标记不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 08:56:07 +00:00

133 lines
6.4 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement, createRef } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatComposer } from "../src/components/chat-composer.tsx";
import {
appendQueuedText,
COMPOSER_QUEUE_LABEL,
COMPOSER_QUEUE_RECALL_LABEL,
queuedDraftSettleAction,
} from "../src/lib/queued-draft.ts";
import { chatReplyAnnouncer } from "../src/lib/chat-reply-announcement.ts";
import { RECTIFICATION_STOPPED_NOTICE } from "../src/lib/rectification-surface-state.ts";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const rectification = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const consultationRun = readFileSync(
new URL("../src/hooks/use-consultation-run.ts", import.meta.url),
"utf8",
);
const messageRow = readFileSync(
new URL("../src/components/chat-message-row.tsx", import.meta.url),
"utf8",
);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("queued drafts append onto one card and settle by outcome", () => {
assert.equal(appendQueuedText("", " 先问事业 "), "先问事业");
assert.equal(appendQueuedText("先问事业", "再问婚姻"), "先问事业\n再问婚姻");
assert.equal(appendQueuedText("先问事业", " "), "先问事业");
assert.equal(queuedDraftSettleAction("completed"), "send");
assert.equal(queuedDraftSettleAction("succeeded"), "send");
assert.equal(queuedDraftSettleAction("stopped"), "restore");
assert.equal(queuedDraftSettleAction("failed"), "restore");
assert.equal(queuedDraftSettleAction("recovering"), "restore");
assert.equal(queuedDraftSettleAction("readonly"), "restore");
});
test("generating does not disable the textarea; Enter queues instead of dropping", () => {
assert.match(composer, /disabled=\{inputDisabled\}/);
assert.match(composer, /queued\?: \{ text: string; onRecall: \(\) => void \}/);
assert.match(composer, /className="composer-queue"/);
assert.match(composer, /COMPOSER_QUEUE_LABEL/);
assert.match(composer, /COMPOSER_QUEUE_RECALL_LABEL/);
assert.doesNotMatch(styles, /composer-queue[\s\S]{0,400}spinner|composer-queue[\s\S]{0,400}skeleton/i);
assert.match(
page,
/inputDisabled=\{sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| \(!profileComplete && \(onboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
);
assert.doesNotMatch(page, /inputDisabled=\{isLoading \|\| sessionMessagesLoading \|\| cancellationPending/);
assert.match(page, /if \(isLoading \|\| cancellationPending\) return void enqueueQueuedDraft\(composerDraftSnapshot\(\)\);/);
assert.match(consultationRun, /queuedDraftSettleAction\(settlePhase\) === "send"/);
assert.match(consultationRun, /function enqueueQueuedDraft/);
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
assert.match(consultationRun, /composerInput\.current\?\.focus\(\{ preventScroll: true \}\)/);
assert.match(rectification, /inputDisabled=\{readonly\}/);
assert.doesNotMatch(
rectification.slice(rectification.indexOf("<ChatComposer"), rectification.indexOf("onStop={stopRun}")),
/inputDisabled=\{!canSend\}/,
);
assert.match(rectification, /if \(busy \|\| regeneratingMessageKey\) \{/);
assert.match(rectification, /queued\.enqueue\(draft\)/);
});
test("a queued card renders above the live textarea", () => {
const html = renderToStaticMarkup(createElement(ChatComposer, {
inputRef: createRef<HTMLTextAreaElement>(),
value: "还想再问一句",
inputLabel: "输入你的问题",
placeholder: "例如:未来半年是否适合换工作?",
maxLength: 500,
inputDisabled: false,
submitLabel: "发送",
submitBlocked: true,
stopVisible: true,
stopLabel: "停止回答",
stopTitle: "停止回答",
queued: { text: "先问事业", onRecall() {} },
onSubmit() {},
onChange() {},
onKeyDown() {},
onStop() {},
}));
assert.match(html, /composer-queue/);
assert.match(html, new RegExp(COMPOSER_QUEUE_LABEL));
assert.match(html, /先问事业/);
assert.match(html, new RegExp(COMPOSER_QUEUE_RECALL_LABEL));
assert.doesNotMatch(html, /<textarea[^>]*\sdisabled=/);
assert.match(html, /还想再问一句/);
});
test("rectification abort settles as stopped, not a failed alert", () => {
assert.match(rectification, /stopped: true/);
assert.match(rectification, /failed: false,\s*stopped: true/);
assert.doesNotMatch(rectification, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
// 原值: 停止提示 prop 写在 rectification-agentic-chat.tsx。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.match(messageRow, /stoppedNotice/);
assert.match(messageRow, /className="message-stopped-notice"/);
assert.equal(RECTIFICATION_STOPPED_NOTICE, "已停止,已生成的内容保留;本次不会扣点。");
assert.match(rectification, /signal: abortController\.signal/);
const choiceFetch = rectification.slice(
rectification.indexOf("const submitStructuredChoice"),
rectification.indexOf("const acceptCandidate"),
);
assert.match(choiceFetch, /runAbort\.current = abortController/);
assert.match(choiceFetch, /signal: abortController\.signal/);
const adoptFetch = rectification.slice(
rectification.indexOf("const acceptCandidate"),
rectification.indexOf("async function copyMessage"),
);
assert.match(adoptFetch, /runAbort\.current = abortController/);
assert.match(adoptFetch, /signal: abortController\.signal/);
assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast");
assert.doesNotMatch(
rectification.slice(rectification.indexOf("{error &&"), rectification.indexOf("{readonly &&")),
/RECTIFICATION_STOPPED_NOTICE/,
);
});