Files
Jyotisha/frontend/tests/chat-navigation-a11y-contract.test.ts
T
Jesse_ChenandClaude Fable 5.1 fb77c86585 test(ui): 侧栏只读模式、共享外壳与列表缓存的合同回归
新增 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
2026-09-16 11:23:11 +00:00

261 lines
16 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 {
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.tshook 的
// 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\}/);
});