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
This commit is contained in:
Jesse_Chen
2026-09-16 11:23:11 +00:00
co-authored by Claude Fable 5.1
parent d9d347236f
commit fb77c86585
10 changed files with 340 additions and 45 deletions
+49 -7
View File
@@ -193,18 +193,54 @@ test("keeps the sidebar brand row free of a duplicate collapse trigger", () => {
assert.doesNotMatch(appSidebar, /SidebarTrigger/);
});
test("leaves the chat document for chart, ephemeris, and reports", () => {
test("reaches chart, ephemeris and reports with links, not a document load", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
// 对话首页用 history.pushState 维护 ?c=App Router 的 router.push 无法离开。
assert.match(appSidebar, /persistLoginSessionReturn\(\);\s*window\.location\.assign\(path\)/);
assert.match(appSidebar, /leaveChat\("\/chart"\)/);
assert.match(appSidebar, /leaveChat\("\/ephemeris"\)/);
assert.match(appSidebar, /leaveChat\("\/reports"\)/);
// 原值:要求 `persistLoginSessionReturn(); window.location.assign(path)`
// 以及三个 `leaveChat("/chart" | "/ephemeris" | "/reports")` 调用点。
// 新值:要求三项由 `NAV_PAGES` 渲染成 `<SidebarMenuLink href=…>``leaveChat()`
// 只保留 `persistLoginSessionReturn()` 与关抽屉,并反向禁止任何
// `window.location.assign`。
// 原因:TASK-sidebar-unify D3/T4。原写法是**有意**的整页刷新,理由写在这里的旧注释
// 里:首页用 `history.pushState` 维护 `?c=``router.push` 离不开首页。
// `<Link>` 没有这个限制——它是 App Router 的导航原语,而不是 `router.push`
// ——所以产品拍板换成客户端跳转,React 树与内存缓存因此能活过这一跳。
// `?c=` 的保存(`persistLoginSessionReturn`)一步没少,仍在跳转之前。
// `/login` 仍是硬跳转,由 chat-navigation-a11y-contract 守着。
assert.match(appSidebar, /function leaveChat\(\) \{\s*persistLoginSessionReturn\(\);/);
assert.match(appSidebar, /\{ href: "\/chart", label: "星盘"/);
assert.match(appSidebar, /\{ href: "\/ephemeris", label: "星历"/);
assert.match(appSidebar, /\{ href: "\/reports", label: "我的报告"/);
assert.match(appSidebar, /<SidebarMenuLink\n\s*className="report-nav-button"/);
assert.doesNotMatch(appSidebar, /window\.location/);
assert.doesNotMatch(appSidebar, /router\.push\("\/chart"\)/);
assert.doesNotMatch(appSidebar, /router\.push\("\/ephemeris"\)/);
assert.doesNotMatch(appSidebar, /useRouter/);
});
test("the same component renders read-only when `/` is not the one mounting it", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
// One component, two modes: `controls` present on `/`, absent everywhere else.
assert.match(appSidebar, /export type AppSidebarControls/);
assert.match(appSidebar, /controls\?: AppSidebarControls;/);
// Read-only rows keep the interactive row's markup — `.session-row` wrapping a
// `.session-main` — and drop only the menu trigger and its reserved column.
const sessionRow = readProjectFile("src/components/sidebar-session-row.tsx");
assert.match(sessionRow, /<div className="session-row" data-readonly="true">/);
assert.match(sessionRow, /<Link\n\s*className="session-main"/);
assert.match(cssBlock('.session-row[data-readonly="true"]'), /grid-template-columns:\s*minmax\(0,\s*1fr\)/);
const readonlyRow = sessionRow.slice(sessionRow.indexOf('data-readonly="true"'), sessionRow.indexOf("const {\n disabled,"));
assert.doesNotMatch(readonlyRow, /session-menu-trigger/);
// The footer is the same 56px `.profile-trigger`, as a link with no chevron.
assert.match(appSidebar, /<Link\n\s*className="profile-trigger"\n\s*href="\/"/);
// And nothing in read-only mode can write: the write callbacks only exist
// inside `controls`, which the secondary layout never passes.
assert.doesNotMatch(appSidebar, /supabase|fetch\(/i);
});
test("uses one collapsed history action instead of icon-only session rows", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /MessageSquareText/);
@@ -486,6 +522,12 @@ test("history renders recency group labels and a silent load-more sentinel", ()
assert.match(appSidebar, /groupSessionsByRecency/);
assert.match(appSidebar, /sidebar-group-label/);
assert.match(appSidebar, /session-list-sentinel/);
assert.match(appSidebar, /sessionControls\.hasMore \? <div ref=\{loadMoreRef\}/);
// 原值:`sessionControls.hasMore ? <div ref={loadMoreRef}`
// 新值:`hasMoreSessions ? <div ref={loadMoreRef}`
// 原因:`sessionControls` 在只读模式下不存在,改从可选的 `controls` 里解构;
// `hasMoreSessions` 是它派生出的稳定布尔值,effect 的依赖数组也用它,
// 否则 exhaustive-deps 会要求整个对象、让观察器每次渲染重建。哨兵本身
// (有下一页才渲染、渲染出来是静默的 sentinel)没变。
assert.match(appSidebar, /hasMoreSessions \? <div ref=\{loadMoreRef\}/);
assert.doesNotMatch(appSidebar, /加载更多|没有更多/);
});