Files
Jyotisha/frontend/tests/composer-isolation-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 6ad0bea320 feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)
TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 12:58:58 +08:00

168 lines
12 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 { homeSurface as pageSource } from "./home-surface.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.ts", import.meta.url), "utf8");
// 原值:homeSurface 连同独立 /people 表单一起检查聊天根草稿。
// 新值:Home 及其业务 hooks;原隔离断言不变。
// 原因:人物编辑表单的局部 draft 不属于聊天根状态,不能把它误判为击键重渲染 Home。
const chatOwnerSource = [
"../src/app/(app)/page.tsx",
"../src/hooks/use-session-management.ts",
"../src/hooks/use-consultation-run.ts",
"../src/hooks/use-profile-onboarding.ts",
"../src/hooks/use-rectification-surface.ts",
"../src/hooks/use-synastry.ts",
].map((path) => readFileSync(new URL(path, import.meta.url), "utf8")).join("\n");
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("keystrokes never re-render the chat page root", () => {
// Given: the composer text used to live in page-level React state.
assert.doesNotMatch(chatOwnerSource, /const \[draft, setDraft\] = useState/);
assert.doesNotMatch(pageSource, /const \[draftTheme, setDraftTheme\] = useState/);
assert.doesNotMatch(pageSource, /const \[draftEntrypoint, setDraftEntrypoint\] = useState/);
// When: the page keeps only the hidden routing values it reads at submit time.
assert.match(pageSource, /const draftTheme = useRef<Theme \| null>\(null\)/);
assert.match(pageSource, /const draftEntrypoint = useRef<ConsultationEntrypoint \| null>\(null\)/);
// Then: every writer the page still exposes updates a ref or the external store.
assert.match(pageSource, /function setDraft\(value: string\) \{\n setComposerDraft\(value\);\n \}/);
assert.match(pageSource, /function setDraftTheme\(theme: Theme \| null\) \{\n draftTheme\.current = theme;\n \}/);
assert.match(pageSource, /function setDraftEntrypoint\(entrypoint: ConsultationEntrypoint \| null\) \{\n draftEntrypoint\.current = entrypoint;\n \}/);
assert.match(pageSource, /import \{ composerDraftSnapshot, setComposerDraft \} from "@\/lib\/composer-draft"/);
assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
});
test("the textarea lives in an isolated composer that owns the draft subscription", () => {
// Given: the page renders the composer without handing it the draft text.
assert.match(pageSource, /import \{ ChatComposer \} from "@\/components\/chat-composer"/);
const composerElement = sourceBetween(pageSource, "<ChatComposer", "/>");
assert.doesNotMatch(composerElement, /\bdraft=/);
assert.doesNotMatch(pageSource, /<Textarea/);
assert.doesNotMatch(chatOwnerSource, /value=\{draft\}/);
// When: the composer resolves the draft itself.
assert.match(composerSource, /useSyncExternalStore\(\n\s*subscribeComposerDraft,\n\s*composerDraftSnapshot,\n\s*serverComposerDraftSnapshot,\n\s*\)/);
// Former lock: `const draft = useComposerDraft()`. The composer still owns the store
// subscription; a surface with its own draft (rectification) may pass `value` instead, and
// must never write the main chat's store (BUG-477).
assert.match(composerSource, /const storeDraft = useComposerDraft\(\);\n\s*const draft = value \?\? storeDraft;/);
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const rectificationSource = rectificationChatSurface;
assert.match(rectificationSource, /<ChatComposer[\s\S]*?value=\{draft\}/);
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
// Then: only the composer subtree reads the value that changes on every keystroke.
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
});
test("the composer keeps its Chinese input, focus and accessibility contract", () => {
// Given: Enter submits only outside an IME composition.
const keyHandler = sourceBetween(pageSource, "function handleComposerKeyDown", "\n\n if (!hydrated");
assert.match(keyHandler, /if \(event\.nativeEvent\.isComposing\) return/);
assert.match(keyHandler, /event\.currentTarget\.form\?\.requestSubmit\(\)/);
assert.match(pageSource, /onKeyDown=\{handleComposerKeyDown\}/);
// When: the page still focuses the same textarea element through its own ref.
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
assert.match(pageSource, /inputRef=\{composerInput\}/);
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
assert.match(pageSource, /composerInput\.current\?\.focus\(\{ preventScroll: true \}\)/);
// Then: the form shell, labels, hit targets and the stop control are unchanged.
assert.match(composerSource, /<form className="composer" onSubmit=\{onSubmit\}>/);
assert.match(composerSource, /aria-label=\{inputLabel\}/);
assert.match(composerSource, /rows=\{1\}/);
assert.match(composerSource, /maxLength=\{maxLength\}/);
assert.match(composerSource, /className="composer-stop"/);
assert.match(composerSource, /aria-label=\{stopLabel\}/);
assert.match(composerSource, /title=\{stopTitle\}/);
assert.match(composerSource, /aria-label=\{submitLabel\} disabled=/);
assert.equal(composerSource.match(/size="icon"/g)?.length, 2);
assert.match(pageSource, /inputLabel=\{!profileComplete && onboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
assert.match(pageSource, /submitLabel=\{!profileComplete && onboardingStep === "name" \? "确认称呼" : "发送"\}/);
});
test("every external draft writer keeps working through the page-owned setters", () => {
// Given: editing clears the hidden routing that a suggested question attached.
assert.match(pageSource, /onChange=\{\(event\) => \{\n\s*setDraft\(event\.target\.value\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);\n\s*setComposerNotice\(""\);\n\s*\}\}/);
// When: each existing write path is inspected.
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "async function startSuggestedConsultation");
const startNewChat = sourceBetween(pageSource, "async function startNewChat(", "function selectSession(");
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface");
const sendClear = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", "if (!resuming && process.env.NODE_ENV === \"development\" && uiPreview.current)");
const sendRestore = sourceBetween(
pageSource,
"if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {",
"setRequestError({",
);
// Then: synastry still fills the composer; session switches clear, stop restores and send clears.
assert.match(chooseSuggested, /setDraft\(question\);\n\s*setDraftTheme\(theme \?\? null\);\n\s*setDraftEntrypoint\(entrypoint\);/);
const startSuggested = sourceBetween(pageSource, "async function startSuggestedConsultation(", "function startDailyStarlanguageConsultation");
assert.match(startSuggested, /await send\(trimmed, theme, entrypoint, null, targetSession\.id/);
assert.doesNotMatch(startSuggested, /setDraft\(/);
assert.match(startNewChat, /setDraft\(""\)/);
assert.match(selectSession, /setDraft\(""\)/);
assert.match(saveOnboardingName, /composerDraftSnapshot\(\)\.replace\(/);
assert.match(saveOnboardingName, /setDraft\(""\)/);
assert.match(stopRestore, /setDraft\(pending\.question\);\n\s*setDraftTheme\(pending\.theme\);\n\s*setDraftEntrypoint\(pending\.entrypoint\);/);
assert.match(sendClear, /setDraft\(""\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);/);
assert.match(sendRestore, /setDraft\(originalQuestion\);\n\s*setDraftTheme\(theme\);\n\s*setDraftEntrypoint\(consultEntrypoint\);/);
assert.match(pageSource, /void send\(composerDraftSnapshot\(\), draftTheme\.current \?\? undefined, draftEntrypoint\.current\)/);
// And: opening a rectification case still empties the composer it replaces.
// 原值: 截到 openRectificationFromHomepage 新值: 截到 openRectificationFromChat 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
const openRectification = sourceBetween(pageSource, "async function openRectificationCase", "async function openRectificationFromChat");
assert.match(openRectification, /setDraft\(""\)/);
});
test("the draft survives a reload without resurrecting a sent question", () => {
// Given: the draft key follows the existing session-scoped storage convention.
assert.match(pageSource, /const pendingConsultationStorageKey = "jyotisha\.pending-consultation"/);
assert.match(draftStoreSource, /export const composerDraftStorageKey = "jyotisha\.composer-draft"/);
assert.match(draftStoreSource, /return window\.sessionStorage;/);
// When: the store restores exactly once and every write mirrors the new value.
assert.match(draftStoreSource, /function restoreComposerDraft\(\) \{\n if \(restored\) return;\n restored = true;\n draft = readStoredComposerDraft\(\);\n\}/);
assert.match(draftStoreSource, /export function composerDraftSnapshot\(\): string \{\n restoreComposerDraft\(\);\n return draft;\n\}/);
assert.match(draftStoreSource, /export function setComposerDraft\(value: string\) \{\n restoreComposerDraft\(\);/);
assert.match(draftStoreSource, /writeStoredComposerDraft\(value\);\n for \(const listener of \[\.\.\.listeners\]\) listener\(\);/);
// Then: clearing after a send removes the key instead of persisting an empty draft.
assert.match(draftStoreSource, /function writeStoredComposerDraft\(value: string\) \{\n if \(!value\) \{\n clearStoredDraft\(\);\n return;\n \}/);
// And: a corrupt, foreign, oversized or stale value is discarded, never surfaced.
const readStored = sourceBetween(draftStoreSource, "export function readStoredComposerDraft()", "function writeStoredComposerDraft");
assert.match(readStored, /typeof text !== "string"/);
assert.match(readStored, /typeof savedAt !== "number"/);
assert.match(readStored, /!Number\.isFinite\(savedAt\)/);
assert.match(readStored, /savedAt > Date\.now\(\)/);
assert.match(readStored, /Date\.now\(\) - savedAt > composerDraftMaxAgeMs/);
assert.match(readStored, /clearStoredDraft\(\);\n return "";\n \}/);
assert.match(readStored, /catch \{\n clearStoredDraft\(\);\n return "";\n \}/);
assert.match(readStored, /text\.slice\(0, composerDraftLimit\)/);
// And: an unavailable storage or a server render degrades to an empty composer.
assert.match(draftStoreSource, /if \(typeof window === "undefined"\) return null;/);
assert.match(draftStoreSource, /export function serverComposerDraftSnapshot\(\): string \{\n return "";\n\}/);
});