649ba32034
Clicking today's reading or a topic card should open a consultation and wait for the model, not leave the question in the input box. Co-authored-by: Cursor <cursoragent@cursor.com>
145 lines
9.9 KiB
TypeScript
145 lines
9.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
|
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");
|
|
|
|
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(pageSource, /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(pageSource, /value=\{draft\}/);
|
|
|
|
// When: the composer resolves the draft itself.
|
|
assert.match(composerSource, /useSyncExternalStore\(\n\s*subscribeComposerDraft,\n\s*composerDraftSnapshot,\n\s*serverComposerDraftSnapshot,\n\s*\)/);
|
|
assert.match(composerSource, /const draft = 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\}/);
|
|
assert.match(pageSource, /composerInput\.current\?\.focus\(\)/);
|
|
|
|
// 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.
|
|
const openRectification = sourceBetween(pageSource, "async function openRectificationCase", "async function openRectificationFromHomepage");
|
|
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\}/);
|
|
});
|