Files
Jyotisha/frontend/tests/composer-isolation-contract.test.ts
T
jesse-uxandGrok (agent loop) d7e8cc46c2 人物目录没到时不再当成没建完档(U11)。
目录还没回来时不闪「看谁的盘」,热启动也不因此走冷路径。已知是空的,仍然算没建完档。月亮星座和天空副句的上升,首页没有现成资料,不新发请求,也不编星座。

Co-Authored-By: Grok (agent loop)
2026-10-27 01:23:18 +08:00

177 lines
13 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.
// 原值:`<Textarea` 直接放在表单里
// 新值:`<InputGroupTextarea`,仍绑定同一份 draft
// 原因:K2 输入框改用 InputGroup,草稿订阅不变
assert.match(composerSource, /<InputGroupTextarea[\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\}/);
// 原值:`<Textarea ref={inputRef}`
// 新值:`<InputGroupTextarea ref={inputRef}`
// 原因:K2 输入框改用 InputGroup,页面仍用同一个 textarea ref 聚焦
assert.match(composerSource, /<InputGroupTextarea\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);
// 原值: 资料未完成且停在称呼步时,标签是「输入你的称呼 / 确认称呼」,判断用 onboardingStep
// 新值: 同一对标签,判断用 shownOnboardingStep;合盘追问仍不走这套称呼标签
// 原因: 人物目录未到时不能把空目录当成「看谁的盘」。称呼步的两句文案不变,#83 的合盘追问也不改成确认称呼。
assert.match(pageSource, /inputLabel=\{!synastryChat && !profileComplete && shownOnboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
assert.match(pageSource, /submitLabel=\{!synastryChat && !profileComplete && shownOnboardingStep === "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\}/);
});