import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { chatReplyAnnouncement, chatReplyAnnouncer, type ChatReplyPhase, } from "../src/lib/chat-reply-announcement.ts"; const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const membershipSource = readFileSync(new URL("../src/app/membership/page.tsx", import.meta.url), "utf8"); const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8"); const announcementSource = readFileSync(new URL("../src/lib/chat-reply-announcement.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("in-app destinations navigate client-side so the chat survives the round trip", () => { // Given: the chat page now owns a router. assert.match(pageSource, /import \{ useRouter \} from "next\/navigation"/); assert.match(pageSource, /const router = useRouter\(\)/); // Then: every same-app destination the user comes back from is a soft push. assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/); assert.match(pageSource, /onOpenRedeem=\{\(\) => router\.push\(membershipHref\("account-menu"\)\)\}/); assert.match(pageSource, /onClick=\{\(\) => router\.push\(membershipHref\("credits"\)\)\}/); assert.match(pageSource, /router\.push\(membershipHref\("insufficient-credits"\)\)/); assert.match(pageSource, /if \(response\.status === 402\) router\.push\(membershipHref\("insufficient-credits"\)\)/); // And: none of them fall back to a document load. assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/); assert.doesNotMatch(pageSource, /window\.location\.assign\(membershipHref\(/); }); test("both insufficient-credit paths are soft so the typed question is not thrown away", () => { const creditGuard = sourceBetween( pageSource, "if (account.credits <= 0 && !account.activeSubscription) {", "const [year, month, day]", ); const sendSource = pageSource.slice(pageSource.indexOf(" async function send(")); // Given: the pre-flight guard runs before send() clears the composer draft. assert.match(creditGuard, /router\.push\(membershipHref\("insufficient-credits"\)\)/); assert.doesNotMatch(creditGuard, /window\.location/); const creditPush = sendSource.indexOf('router.push(membershipHref("insufficient-credits"))'); const sendClearsComposer = sendSource.indexOf("conversationAnchor.anchorToLatest();\n setDraft(\"\");"); assert.ok(creditPush >= 0 && sendClearsComposer > creditPush); assert.equal(pageSource.match(/router\.push\(membershipHref\("insufficient-credits"\)\)/g)?.length, 2); }); test("auth redirects stay hard document loads so stale session state cannot survive", () => { // Given: a 401 means the client is holding a session the server has rejected. // Then: every login redirect is a deliberate full page load, not router.push. assert.match(pageSource, /function redirectToLogin\(\): never \{\n window\.location\.replace\("\/login"\);/); assert.equal(pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 4); assert.equal(pageSource.match(/window\.location\.replace\("\/login"\)/g)?.length, 1); assert.doesNotMatch(pageSource, /router\.push\("\/login"\)/); assert.doesNotMatch(pageSource, /router\.replace\("\/login"\)/); // And: the login redirect after sign-out drops the whole React tree with the session. const signOut = sourceBetween(pageSource, "async function signOut()", "function chooseSuggestedQuestion"); assert.match(signOut, /await selfHostedOtpActions\.signOut\(\);\n\s*window\.location\.assign\("\/login"\)/); // And: the sibling membership surface keeps the same precedent. assert.match(membershipSource, /import \{ useSearchParams \} from "next\/navigation"/); assert.equal(membershipSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 3); assert.doesNotMatch(membershipSource, /router\.(push|replace)\("\/login"\)/); }); test("bootstrap failure retries with a hard reload because refresh cannot rerun client bootstrap", () => { // Given: the account, catalog and sessions are fetched from a mount-once client effect, // which router.refresh() would not re-run because it never remounts the client tree. assert.match(pageSource, /onClick=\{\(\) => window\.location\.reload\(\)\}>重试<\/button>/); assert.doesNotMatch(pageSource, /router\.refresh\(\)/); assert.match(pageSource, /void loadCloudData\(\);\n\s*return \(\) => \{[\s\S]*?\n \}, \[\]\);/); }); test("the streaming reply announces state transitions, never streamed deltas", () => { // Given: one page-level status region outside every aria-busy subtree. assert.match( pageSource, /
\n\s*\{replyAnnouncement\}<\/span>/, ); // Then: the region is fed a phase-derived string, not the streaming text. assert.match(pageSource, /const replyAnnouncement = chatReplyAnnouncement\(replyPhase, activeReplyOutcome\?\.replyOrdinal \?\? 0\)/); assert.doesNotMatch(announcementSource, /activeStreamingText|streamingReply|answer\.delta/); assert.doesNotMatch(pageSource, /aria-live=[^>]*\{activeStreamingText/); assert.doesNotMatch(pageSource, /\{activeStreamingText\}<\/span>/); // And: the old start-only region that lived inside the busy message list is gone. assert.doesNotMatch(pageSource, /\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/); assert.match(pageSource, /
\n\s*\{chatMessageViews\(/); }); test("the reply phase covers start, completion and every terminal state", () => { const phaseDerivation = sourceBetween(pageSource, "const replyPhase: ChatReplyPhase = isLoading", "const accountId ="); assert.match(phaseDerivation, /consultationPhase === "recovering" \? "recovering" : "generating"/); assert.match(phaseDerivation, /activeReplyOutcome\?\.phase \?\? "idle"/); assert.match(pageSource, /const activeReplyOutcome = replyOutcome && replyOutcome\.sessionId === activeSession\?\.id \? replyOutcome : null/); // Given: each terminal transition records its outcome from an event handler, not an effect. assert.match(pageSource, /setReplyOutcome\(\{\n\s*sessionId,\n\s*phase: "completed",\n\s*replyOrdinal: completedSession\.messages\.filter\(\(message\) => message\.role === "assistant"\)\.length,\n\s*\}\)/); assert.match(pageSource, /setReplyOutcome\(\{ sessionId, phase: "failed", replyOrdinal: 0 \}\)/); assert.match(pageSource, /setReplyOutcome\(\{ sessionId: pending\.sessionId, phase: "stopped", replyOrdinal: 0 \}\)/); // And: a new question clears the previous outcome so a stale reply is never re-announced. assert.match(pageSource, /setRequestError\(null\);\n\s*setReplyOutcome\(null\);\n\s*if \(!resuming\) \{\n\s*setComposerNotice\(""\)/); }); test("completion is announced with where to find the reply, and start says one is coming", () => { assert.equal(chatReplyAnnouncement("generating", 0), "Jyotisha 正在回答,完成后会提示你阅读。"); assert.equal( chatReplyAnnouncement("completed", 3), "Jyotisha 已回答完毕,第 3 条回答已显示在对话区末尾,可以开始阅读。", ); // Given: two consecutive replies would otherwise repeat identical text and stay unspoken. assert.notEqual(chatReplyAnnouncement("completed", 3), chatReplyAnnouncement("completed", 4)); // And: every announcement is simplified Chinese in the existing product voice. for (const phase of ["generating", "completed"] as const) { const text = chatReplyAnnouncement(phase, 1); assert.match(text, /^Jyotisha /); assert.doesNotMatch(text.replace("Jyotisha", ""), /[A-Za-z]/); } }); test("toast-owned and alert-owned events are deliberately silent in the status region", () => { // Given: sonner mounts its own polite live region for every chat notice. assert.match(noticeSource, /import \{ toast \} from "sonner"/); // Then: stop, recovery and disconnect stay with the toast instead of double-announcing. assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast"); assert.equal(chatReplyAnnouncer("recovering"), "chat_notice_toast"); assert.equal(chatReplyAnnouncement("stopped", 0), ""); assert.equal(chatReplyAnnouncement("recovering", 0), ""); // And: a failed reply belongs to the assertive conversation alert, not the polite region. assert.equal(chatReplyAnnouncer("failed"), "conversation_alert"); assert.equal(chatReplyAnnouncement("failed", 0), ""); assert.match(pageSource, /

\{activeError\}<\/p>/); // And: only the two live-region phases ever produce spoken text. const phases: readonly ChatReplyPhase[] = ["idle", "generating", "recovering", "completed", "stopped", "failed"]; const spoken = phases.filter((phase) => chatReplyAnnouncement(phase, 1) !== ""); assert.deepEqual(spoken, ["generating", "completed"]); }); test("the recovered background reply tells the reader where it landed", () => { assert.equal(pageSource.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 2); assert.doesNotMatch(pageSource, /setComposerNotice\("回答已恢复。"\)/); }); test("assertive announcement is reserved for genuine errors", () => { // Given: the bootstrap failure screen is the only assertive surface on the page. assert.match(pageSource, /

\n\s*
/); assert.doesNotMatch(pageSource, /aria-live="assertive"/); // And: the still-loading screen stays polite and marks itself busy. assert.match(pageSource, /
/); }); test("the onboarding typewriter announces the finished line once instead of each character", () => { const onboardingMessage = sourceBetween(pageSource, "function OnboardingChatMessage(", "function isProfileComplete("); // Given: the visibly typed text is hidden from assistive technology. assert.match(onboardingMessage, /className=\{`onboarding-stream[^`]*`\} aria-hidden="true"/); // Then: only the completed line reaches the polite region, as one atomic unit. assert.match( onboardingMessage, /\{length >= text\.length \? text : ""\}<\/span>/, ); assert.doesNotMatch(onboardingMessage, /aria-live="polite">\{visibleText\}|aria-live="polite">\{protectedVisibleText\}/); assert.doesNotMatch(onboardingMessage, /aria-busy/); }); test("aria-busy marks the updating message list without swallowing the status region", () => { // Given: the activity status inside a streamed row is itself a role="status" region. const activitySource = readFileSync( new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8", ); assert.match(activitySource, /role="status"/); // Then: aria-busy on the message list suppresses that per-phase chatter while streaming, // and the reply status region sits outside it so completion is still spoken. assert.match(pageSource, /
/); const messageList = sourceBetween(pageSource, '
', "
"); assert.doesNotMatch(messageList, /aria-live|role="status"/); }); test("the jump-to-latest control stays keyboard reachable and labelled", () => { const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ""); const buttonTag = sourceBetween(jumpControl, ""); // Given: a real button in document order, never removed from the tab sequence. assert.match(jumpControl, /