新增 8 条:`sidebar-data-cache.test.ts`(命中不重拉 / 过期重拉一次 / 写操作后 拿到新标题并逐条锁住五个写路径 / 双账户不串 / 只存内存 / 401 清空)、 `sidebar-state.test.ts` +2(收起后重挂仍收起,含三种降级;移动端不读不写)、 `sidebar-contract.test.ts` +1(只读模式只少三样)、`chart-page-view.test.tsx` +1 (`(secondary)` layout 恰好挂一份只读侧栏,数据 hook 无写方法)。 改写 9 处既有断言,每处带「原值 / 新值 / 原因」三栏注释,均未削弱: `window.location.assign(path)` → `<SidebarMenuLink href>` 并追加反向断言; `onOpenReports` / `useRouter` 改成 doesNotMatch;`SecondaryShell` → `SecondaryHeader`; 导航顺序改在 `NAV_PAGES` 常量里量;两个 render 辅助改为裹 `SidebarProvider` (provider 上移到 layout);三处源码路径跟随路由组移动。 测试总数 3391 → 3399,失败清单与基线逐条一致(47 条均为无 Docker 的既有缺口)。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
261 lines
16 KiB
TypeScript
261 lines
16 KiB
TypeScript
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";
|
||
|
||
import { homeSurface as pageSource } from "./home-surface.ts";
|
||
const billingHookSource = readFileSync(new URL("../src/hooks/use-billing-panel.ts", 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", () => {
|
||
// 原值: `assert.match(pageSource, /import \{ useRouter \}/)` +
|
||
// `assert.match(pageSource, /const router = useRouter\(\)/)` +
|
||
// `assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/)`
|
||
// 新值: `page.tsx` 不再持有 router;星盘 / 星历 / 我的报告由侧栏自己用 `<Link>` 走
|
||
// 原因: TASK-sidebar-unify T4。`onOpenReports` 是死 prop——侧栏把它解构成
|
||
// `_onOpenReports` 从未调用,真正的跳转是 `window.location.assign` 的整页刷新。
|
||
// 删掉死 prop 后 `router` 在 `page.tsx` 里再无消费者(`useConsultationRun`
|
||
// 的 `router` 参数同样是解构成 `_router` 的死参数,一并删)。断言主语(站内
|
||
// 目的地不得整页刷新)没变,且从「首页持有 router」升级成「侧栏用 Link」,
|
||
// 由 sidebar-contract 的 `reaches chart, ephemeris and reports with links`
|
||
// 与本条下面的 doesNotMatch 共同守住。
|
||
assert.doesNotMatch(pageSource, /useRouter/);
|
||
assert.doesNotMatch(pageSource, /onOpenReports/);
|
||
const sidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
|
||
assert.match(sidebarSource, /<SidebarMenuLink\n\s*className="report-nav-button"/);
|
||
assert.doesNotMatch(sidebarSource, /window\.location/);
|
||
|
||
assert.match(pageSource, /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/);
|
||
assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
|
||
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||
assert.match(pageSource, /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||
|
||
// And: membership stays in-page; leaving chat is a client-side <Link> now.
|
||
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, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||
assert.doesNotMatch(creditGuard, /window\.location/);
|
||
const creditPush = sendSource.indexOf('openAccountDialog("billing", { source: "insufficient-credits" })');
|
||
const sendClearsComposer = sendSource.indexOf("conversationAnchor.anchorToLatest();\n setDraft(\"\");");
|
||
assert.ok(creditPush >= 0 && sendClearsComposer > creditPush);
|
||
assert.equal(pageSource.match(/openAccountDialog\("billing", \{ source: "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.
|
||
// Former value: /function redirectToLogin\(\): never \{\n window\.location\.replace\("\/login"\);/
|
||
// 401 now stashes a UUID ?c= in sessionStorage so `/` can restore the session
|
||
// without adding a `next` query to the login page.
|
||
assert.match(pageSource, /function redirectToLogin\(\): never \{/);
|
||
assert.match(pageSource, /persistLoginSessionReturn\(\);\n window\.location\.replace\("\/login"\);/);
|
||
// 原值: pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length === 4
|
||
// 新值: 3
|
||
// 原因: 8144fca2 删除建议问题请求,连同它的 OnboardingAuthenticationError 分支里
|
||
// 那一次 window.location.assign("/login")。少的是调用点,不是把硬跳转换成了
|
||
// router.push——同 test 里两条 doesNotMatch 仍然成立。
|
||
assert.equal(pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 3);
|
||
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.
|
||
// 原值: sourceBetween(..., "async function signOut()", "return {\n refreshAccount,")
|
||
// 新值: 同一起点,终点改成 "return {\n profile,"
|
||
// 原因: 状态下沉第二批把六个 profile 状态搬进 use-profile-onboarding.ts,hook 的
|
||
// return 现在以 profile 开头,refreshAccount 不再是第一个字段。切的还是
|
||
// signOut 函数体,断言主语(退出后硬跳 /login)一字未改。
|
||
const signOut = sourceBetween(pageSource, "async function signOut()", "return {\n profile,");
|
||
assert.match(signOut, /await selfHostedOtpActions\.signOut\(\);\n\s*window\.location\.assign\("\/login"\)/);
|
||
|
||
// And: the billing pane keeps the same 401 precedent without a dedicated membership page.
|
||
assert.doesNotMatch(billingHookSource, /import \{ useSearchParams \} from "next\/navigation"/);
|
||
assert.equal(billingHookSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 4);
|
||
assert.doesNotMatch(billingHookSource, /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,
|
||
/<main className="chat-app">\n\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{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, /<span className="sr-only" aria-live="polite">\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/);
|
||
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*<ChatTranscript/);
|
||
});
|
||
|
||
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, /<p className="error-message" role="alert">\{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, /<main className="app-loading app-loading-error">\n\s*<div className="app-loading-content" role="alert">/);
|
||
assert.doesNotMatch(pageSource, /aria-live="assertive"/);
|
||
|
||
// And: the still-loading screen stays polite and marks itself busy.
|
||
assert.match(pageSource, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
|
||
});
|
||
|
||
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,
|
||
/<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{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, /<div className="message-list" aria-busy=\{isLoading\}>/);
|
||
const messageList = sourceBetween(pageSource, '<div className="message-list" aria-busy={isLoading}>', "</div>");
|
||
assert.doesNotMatch(messageList, /aria-live|role="status"/);
|
||
});
|
||
|
||
test("the jump-to-latest control stays keyboard reachable and labelled", () => {
|
||
// Former locks pinned the inline Tailwind button in page.tsx (`min-h-11`, `focus-visible:ring-3`,
|
||
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
|
||
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
|
||
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
|
||
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
|
||
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||
|
||
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
|
||
const buttonTag = sourceBetween(jumpSource, "<button", ">");
|
||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
|
||
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
|
||
|
||
// Given: a real button in document order, never removed from the tab sequence.
|
||
assert.match(jumpSource, /<button\b/);
|
||
assert.match(buttonTag, /type="button"/);
|
||
assert.doesNotMatch(buttonTag, /tabIndex/);
|
||
assert.doesNotMatch(buttonTag, /aria-hidden/);
|
||
assert.doesNotMatch(buttonTag, /disabled/);
|
||
|
||
// And: a visible label, a matching accessible name, a focus ring and a 44px target.
|
||
assert.match(jumpSource, /aria-label="跳到最新"/);
|
||
assert.match(jumpSource, /\n\s*跳到最新\n/);
|
||
assert.match(styles, /\.jump-to-latest__button:focus-visible \{[^}]*outline: 3px solid/);
|
||
assert.match(buttonRule, /min-height: 44px/);
|
||
assert.match(buttonRule, /min-width: 44px/);
|
||
|
||
// And: the icon is decorative, and pointer-events never gate keyboard activation.
|
||
assert.match(jumpSource, /<ArrowDown aria-hidden="true"/);
|
||
assert.match(wrapRule, /pointer-events: none/);
|
||
assert.match(buttonRule, /pointer-events: auto/);
|
||
assert.match(jumpSource, /onClick=\{onClick\}/);
|
||
});
|