From fb77c86585799caf1ea67904d306435a79bb43cd Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 11:23:07 +0000 Subject: [PATCH] =?UTF-8?q?test(ui):=20=E4=BE=A7=E6=A0=8F=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=A8=A1=E5=BC=8F=E3=80=81=E5=85=B1=E4=BA=AB=E5=A4=96?= =?UTF-8?q?=E5=A3=B3=E4=B8=8E=E5=88=97=E8=A1=A8=E7=BC=93=E5=AD=98=E7=9A=84?= =?UTF-8?q?=E5=90=88=E5=90=8C=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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)` → `` 并追加反向断言; `onOpenReports` / `useRouter` 改成 doesNotMatch;`SecondaryShell` → `SecondaryHeader`; 导航顺序改在 `NAV_PAGES` 常量里量;两个 render 辅助改为裹 `SidebarProvider` (provider 上移到 layout);三处源码路径跟随路由组移动。 测试总数 3391 → 3399,失败清单与基线逐条一致(47 条均为无 Docker 的既有缺口)。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- frontend/tests/chart-page-view.test.tsx | 94 ++++++++++++++---- frontend/tests/chart-view-route.test.ts | 6 +- .../chat-navigation-a11y-contract.test.ts | 27 +++-- frontend/tests/ephemeris-page.test.tsx | 17 +++- frontend/tests/personal-report-entry.test.ts | 14 ++- frontend/tests/personal-report-view.test.ts | 10 +- frontend/tests/sidebar-contract.test.ts | 56 +++++++++-- frontend/tests/sidebar-data-cache.test.ts | 99 +++++++++++++++++++ frontend/tests/sidebar-state.test.ts | 56 +++++++++++ frontend/tests/stale-client-recovery.test.ts | 6 +- 10 files changed, 340 insertions(+), 45 deletions(-) create mode 100644 frontend/tests/sidebar-data-cache.test.ts diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 07447fd8..260656ca 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import test from "node:test"; import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx"; +import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts"; @@ -69,10 +70,19 @@ async function okView(western: Record | null = null): Promise)` + 新值:外面裹一层 `SidebarProvider` + 原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx` + (TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的 + `SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */ +function withSidebarProvider(element: React.ReactElement) { + return renderToStaticMarkup(React.createElement(SidebarProvider, null, element)); +} + test("the five tabs render and can each be selected", async () => { const view = await okView(); for (const tab of CHART_VIEW_TABS) { - const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: tab.id })); + const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab.id })); // 原值: 直接计算 · 打开即有 · 不消耗点数 // 新值: 主盘直接算 · 分盘按需 · 不消耗点数 // 原因: BUG-717 「打开即有」是速度承诺,且印在失败页上 @@ -103,8 +113,8 @@ test("the five tabs render and can each be selected", async () => { test("unavailable western and qizheng tabs stay explanatory", async () => { const view = await okView(); - const western = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: "western" })); - const qizheng = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: "qizheng" })); + const western = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "western" })); + const qizheng = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "qizheng" })); assert.doesNotMatch(western, /报错|出错了|500/); assert.doesNotMatch(qizheng, /报错|出错了|500/); assert.match(western, /不能换算/); @@ -113,7 +123,7 @@ test("unavailable western and qizheng tabs stay explanatory", async () => { test("a tropical packet draws the western wheel instead of the unavailable copy", async () => { const view = await okView(golden.western); - const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: "western" })); + const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "western" })); assert.match(markup, /回归黄道本命盘/); assert.doesNotMatch(markup, /西洋盘这一栏要等回归黄道端点上线/); }); @@ -126,16 +136,35 @@ test("the North Indian svg is imported in place and its export signature is unch }); test("sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的报告", () => { + // 原值:三个页面项的中文标签直接写在 `` 的 JSX 里,顺序用 + // `header.indexOf(">星盘<")` 之类量。 + // 新值:标签收进模块常量 `NAV_PAGES`,头部只剩 `NAV_PAGES.map(...)`;顺序改在 + // 常量里量,并另外断言这一段仍排在「新建对话」之后。 + // 原因:TASK-sidebar-unify T1 把次级页那份侧栏收编进同一个组件,三项在两种模式下 + // 必须逐字一致,写两遍就会再次分叉。断言主语(新建对话 → 星盘 → 星历 → + // 我的报告,且「我的报告」未改名)一字未改。 const header = sidebar.slice(sidebar.indexOf("")); const newChat = header.indexOf("新建对话"); - const chart = header.indexOf(">星盘<"); - const ephemeris = header.indexOf(">星历<"); - const reports = header.indexOf("我的报告"); - assert.ok(newChat >= 0 && chart > newChat && ephemeris > chart && reports > ephemeris); - assert.match(sidebar, /leaveChat\("\/chart"\)/); - assert.match(sidebar, /leaveChat\("\/ephemeris"\)/); - assert.match(sidebar, /tooltip="星盘"/); - assert.match(sidebar, /tooltip="星历"/); + const navPages = header.indexOf("NAV_PAGES.map"); + assert.ok(newChat >= 0 && navPages > newChat); + + const pages = sidebar.slice(sidebar.indexOf("const NAV_PAGES"), sidebar.indexOf("] as const;")); + const chart = pages.indexOf('"星盘"'); + const ephemeris = pages.indexOf('"星历"'); + const reports = pages.indexOf('"我的报告"'); + assert.ok(chart >= 0 && ephemeris > chart && reports > ephemeris); + // 原值:`leaveChat("/chart")` / `leaveChat("/ephemeris")` —— 两个会走 + // `window.location.assign(path)` 的按钮回调。 + // 新值:三个页面项统一由 `NAV_PAGES` 渲染成 ``,并追加 + // 反向断言:侧栏里不得再出现 `window.location.assign("/chart"…)` 这类整页跳转。 + // 原因:TASK-sidebar-unify D3/T4——首页进次级页曾是整页刷新,React 树、会话列表、 + // 账户全部清零。断言主语(三项都在、顺序正确、有 tooltip)没变, + // 新增的 doesNotMatch 比原断言更强。 + assert.match(sidebar, /\{ href: "\/chart", label: "星盘"/); + assert.match(sidebar, /\{ href: "\/ephemeris", label: "星历"/); + assert.match(sidebar, /\{ href: "\/reports", label: "我的报告"/); + assert.match(sidebar, /tooltip=\{label\}/); + assert.doesNotMatch(sidebar, /window\.location\.assign/); }); test("the parameters are one key/value table, not a per-breakpoint duplicate", () => { @@ -185,7 +214,7 @@ test("the planet table scrolls inside its own box instead of widening the page", }); test("an incomplete profile page has no loading animation", () => { - const markup = renderToStaticMarkup(React.createElement(ChartPageView, { + const markup = withSidebarProvider(React.createElement(ChartPageView, { view: { status: "birth_profile_incomplete", billed: false, message: "还没有可用来排盘的出生资料。" }, })); assert.match(markup, /还没有可用来排盘的出生资料/); @@ -194,13 +223,15 @@ test("an incomplete profile page has no loading animation", () => { }); test("the chart page shell is visible before the natal chart arrives", () => { - const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view: null })); + const markup = withSidebarProvider(React.createElement(ChartPageView, { view: null })); assert.match(markup, />星盘 { }); test("failure copy has no eyebrow and does not promise a later retry", () => { - const markup = renderToStaticMarkup(React.createElement(ChartPageView, { + const markup = withSidebarProvider(React.createElement(ChartPageView, { view: { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable }, })); assert.match(markup, new RegExp(CHART_VIEW_COPY.unavailable)); @@ -217,7 +248,7 @@ test("failure copy has no eyebrow and does not promise a later retry", () => { test("a pending western tab uses inline waiting copy instead of a spinner", async () => { const view = await okView(); - const markup = renderToStaticMarkup(React.createElement(ChartPageView, { + const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "western", pendingLayers: new Set(["western"]), @@ -225,3 +256,24 @@ test("a pending western tab uses inline waiting copy instead of a spinner", asyn assert.match(markup, /这一栏还没拿到/); assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i); }); + +test("the (secondary) layout mounts one read-only sidebar for all four routes", () => { + const layout = readFileSync(new URL("../src/app/(secondary)/layout.tsx", import.meta.url), "utf8"); + + // One provider, one sidebar, one inset — for /chart, /ephemeris, /reports and + // /reports/[reportId] together. Each page used to mount its own. + assert.match(layout, //); + assert.match(layout, //); + assert.equal(layout.match(//g)?.length, 1); + + // Read-only: no `controls`, so no rename / archive / delete / account menu. + assert.doesNotMatch(layout, /controls=/); + assert.match(layout, /useSidebarData\(\)/); + + // The data hook never writes, and the pages never mount a second shell. + const hook = readFileSync(new URL("../src/hooks/use-sidebar-data.ts", import.meta.url), "utf8"); + assert.doesNotMatch(hook, /method:\s*"(?:POST|PATCH|PUT|DELETE)"/); + assert.match(hook, /fetch\("\/api\/sessions\?limit=40"/); + assert.match(hook, /fetch\("\/api\/account"/); +}); diff --git a/frontend/tests/chart-view-route.test.ts b/frontend/tests/chart-view-route.test.ts index 9768dfb6..ce9bcb7c 100644 --- a/frontend/tests/chart-view-route.test.ts +++ b/frontend/tests/chart-view-route.test.ts @@ -20,7 +20,11 @@ const routeSource = readFileSync(new URL("../src/app/api/chart-view/route.ts", i const serviceSource = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8"); const loadSource = readFileSync(new URL("../src/lib/chart-view-load.ts", import.meta.url), "utf8"); const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); -const chartPageSource = readFileSync(new URL("../src/app/chart/page.tsx", import.meta.url), "utf8"); +// 原值:`../src/app/chart/page.tsx` +// 新值:`../src/app/(secondary)/chart/page.tsx` +// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。 +// 路由组括号不进 URL,`/chart` 一字未改;这里改的只是源码位置。 +const chartPageSource = readFileSync(new URL("../src/app/(secondary)/chart/page.tsx", import.meta.url), "utf8"); const profile = { name: "示例", diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index 8f96de5f..9534f9ea 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -22,20 +22,29 @@ function sourceBetween(source: string, startMarker: string, endMarker: string) { } 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\(\)/); + // 原值: `assert.match(pageSource, /import \{ useRouter \}/)` + + // `assert.match(pageSource, /const router = useRouter\(\)/)` + + // `assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/)` + // 新值: `page.tsx` 不再持有 router;星盘 / 星历 / 我的报告由侧栏自己用 `` 走 + // 原因: 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, / router\.push\("\/reports"\)\}/); - assert.match(pageSource, /onOpenBilling=\{\(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)\}/); + 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 uses a document load in the sidebar. + // And: membership stays in-page; leaving chat is a client-side now. assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/); assert.doesNotMatch(pageSource, /window\.location\.assign\(membershipHref\(/); }); diff --git a/frontend/tests/ephemeris-page.test.tsx b/frontend/tests/ephemeris-page.test.tsx index 392fcc16..8b5bc221 100644 --- a/frontend/tests/ephemeris-page.test.tsx +++ b/frontend/tests/ephemeris-page.test.tsx @@ -5,6 +5,7 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { EphemerisView } from "../src/components/ephemeris/ephemeris-page.tsx"; +import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/ephemeris-contract.ts"; import { EPHEMERIS_COPY, @@ -17,7 +18,11 @@ import { Object.assign(globalThis, { React }); const pageSource = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8"); -const routePage = readFileSync(new URL("../src/app/ephemeris/page.tsx", import.meta.url), "utf8"); +// 原值:`../src/app/ephemeris/page.tsx` +// 新值:`../src/app/(secondary)/ephemeris/page.tsx` +// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。 +// 路由组括号不进 URL,`/ephemeris` 一字未改;这里改的只是源码位置。 +const routePage = readFileSync(new URL("../src/app/(secondary)/ephemeris/page.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8")); const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8")); @@ -52,8 +57,16 @@ function payloadFor(date: "2026-09-14" | "2026-09-15", events: EphemerisOkRespon return parsed; } +/* 原值:直接 `renderToStaticMarkup()` + 新值:外面裹一层 `SidebarProvider` + 原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx` + (TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的 + `SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */ +function withSidebarProvider(element: React.ReactElement) { + return renderToStaticMarkup(React.createElement(SidebarProvider, null, element)); +} function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "2026-09-15") { - return renderToStaticMarkup(React.createElement(EphemerisView, { + return withSidebarProvider(React.createElement(EphemerisView, { date, today: "2026-09-15", payload, diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts index 99adca07..d250afc2 100644 --- a/frontend/tests/personal-report-entry.test.ts +++ b/frontend/tests/personal-report-entry.test.ts @@ -216,9 +216,19 @@ test("report centre and ready reader scroll inside the chat shell lock", () => { test("entry is global in the sidebar and absent from the active session header", () => { assert.match(sidebarSource, /我的报告/); - assert.match(sidebarSource, /onOpenReports/); - assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/); + // 原值:`assert.match(sidebarSource, /onOpenReports/)` 与 + // `assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/)` + // 新值:断言侧栏自己用 `` 走这一步,且 `page.tsx` + // 不再传 `onOpenReports`、也不再持有 router + // 原因:TASK-sidebar-unify T4。`onOpenReports` 从 D9 起就是死 prop——侧栏把它解构成 + // `_onOpenReports` 从未调用过,真正的跳转走的是 `leaveChat("/reports")` 的整页 + // 刷新。删掉死 prop,跳转改客户端导航。入口仍然只在侧栏一处,断言主语没变; + // 新增的两条 doesNotMatch 比原断言更强。 + assert.match(sidebarSource, /\{ href: "\/reports", label: "我的报告"/); + assert.doesNotMatch(sidebarSource, /onOpenReports/); + assert.doesNotMatch(pageSource, /onOpenReports/); assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/); + assert.doesNotMatch(pageSource, /useRouter/); assert.doesNotMatch(pageSource, /GeneratePersonalReportButton|reportEntryVisible|reportEvidenceState/); // Was `justify-content: center`, which is what put 我的报告 in the middle of // the mobile drawer (BUG-438). The base layer is left-aligned now; centering diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index ae753831..878f3bec 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -446,10 +446,16 @@ test("the reader renders inside the app shell, in every phase", () => { new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8", ); - assert.match(pageSource, /import \{ SecondaryShell \} from "@\/components\/secondary-shell";/); + // 原值:`SecondaryShell` 的 import 与 `` 的出现次数 + // 新值:`SecondaryHeader` 的 import 与 `` 的出现次数 + // 原因:TASK-sidebar-unify D2 把 provider + 侧栏 + inset 上移到 + // `app/(secondary)/layout.tsx`,`SecondaryShell` 拆剩 46px 顶栏并改名 + // `SecondaryHeader`。断言主语(每个阶段都在 app 外壳里、都恰好带一次外壳、 + // 没有一个阶段返回裸 `
`)一字未改。 + assert.match(pageSource, /import \{ SecondaryHeader \} from "@\/components\/secondary-header";/); // No phase may return a bare
: that would nest inside the shell's own. assert.doesNotMatch(pageSource, /
/g) ?? []; + const shells = pageSource.match(//g) ?? []; const phases = pageSource.match(/className="personal-report-(?:state|reader)"/g) ?? []; assert.equal(shells.length, phases.length, "every rendered phase is wrapped exactly once"); assert.ok(phases.length >= 8, `all report phases render, got ${phases.length}`); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index 52b7405f..32556018 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -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` 渲染成 ``,`leaveChat()` + // 只保留 `persistLoginSessionReturn()` 与关抽屉,并反向禁止任何 + // `window.location.assign`。 + // 原因:TASK-sidebar-unify D3/T4。原写法是**有意**的整页刷新,理由写在这里的旧注释 + // 里:首页用 `history.pushState` 维护 `?c=`,`router.push` 离不开首页。 + // `` 没有这个限制——它是 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, / { + 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, /
/); + assert.match(sessionRow, / { 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 \?
({ + id, + title, + pinned: false, + archived: false, + updatedAt: 1, +}); + +const account = { + name: "示例账户", + email: "example@invalid.test", + credits: 0, + initial: "示", + avatar: null, +}; + +test("the read-only sidebar reuses a fresh entry instead of fetching again", () => { + invalidateSidebarCache(); + assert.equal(readSidebarCache(), null); + + writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "第一条")], account, fetchedAt: 1_000 }); + const entry = readSidebarCache(); + assert.ok(entry); + assert.equal(entry.sessions[0]?.title, "第一条"); + + // Inside the window: the hook returns without touching the network. + assert.equal(sidebarCacheIsFresh(entry, 1_000), true); + assert.equal(sidebarCacheIsFresh(entry, 1_000 + SIDEBAR_CACHE_TTL_MS - 1), true); + + // Past it: shown from cache first, refreshed in the background — exactly one + // refetch, because the write that follows resets `fetchedAt`. + assert.equal(sidebarCacheIsFresh(entry, 1_000 + SIDEBAR_CACHE_TTL_MS), false); + assert.equal(sidebarCacheIsFresh(entry, 60_000_000), false); + assert.equal(sidebarCacheIsFresh(null, 1_000), false); + + // A clock that jumped backwards must not read as fresh forever. + assert.equal(sidebarCacheIsFresh(entry, 0), false); +}); + +test("a session write on `/` makes the next secondary page read the new title", () => { + invalidateSidebarCache(); + writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "旧标题")], account, fetchedAt: 1_000 }); + assert.equal(readSidebarCache()?.sessions[0]?.title, "旧标题"); + + invalidateSidebarCache(); + assert.equal(readSidebarCache(), null); + assert.equal(sidebarCacheIsFresh(readSidebarCache(), 1_001), false); + + // Every write path on `/` calls it: create, rename, delete, pin, archive. + for (const marker of [ + /await persistSession\(nextSession\);\n\s*\/\*[\s\S]*?\*\/\n\s*invalidateSidebarCache\(\);/, + /删除聊天记录失败"\);\n\s*invalidateSidebarCache\(\);/, + /\{ pinned: nextPinned \}, "update"\)\.then\(invalidateSidebarCache\)/, + /\{ archived_at: nextArchivedAt \}, "update"\)\.then\(invalidateSidebarCache\)/, + /\);\n\s*invalidateSidebarCache\(\);\n\s*return nextSession;/, + ]) { + assert.match(sessionHookSource, marker); + } +}); + +test("a second account in the same tab never reads the first one's rows", () => { + invalidateSidebarCache(); + writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "甲的对话")], account, fetchedAt: 1_000 }); + writeSidebarCache({ accountId: "acct-2", sessions: [row("b", "乙的对话")], account, fetchedAt: 2_000 }); + + assert.equal(readSidebarCache()?.accountId, "acct-2"); + assert.equal(readSidebarCache()?.sessions[0]?.title, "乙的对话"); + invalidateSidebarCache(); +}); + +test("the cache is memory only, and a 401 empties it", () => { + // Nothing durable: a stale list surviving a browser restart is worse than one + // fetch, and it would outlive a sign-out. + const cacheSource = readFileSync(new URL("../src/lib/sidebar-data-cache.ts", import.meta.url), "utf8"); + assert.doesNotMatch(cacheSource, /localStorage|sessionStorage|document\.cookie|indexedDB/i); + + assert.match(hookSource, /if \(sidebarCacheIsFresh\(cached, Date\.now\(\)\)\) return;/); + assert.match(hookSource, /invalidateSidebarCache\(\);\n\s*setState\(\{ sessions: \[\], account: null, settled: true, signedOut: true \}\);/); + + // Read synchronously at mount so a cached list is on screen in the first + // frame — no skeleton, no spinner, per the unified-loading ruling. + assert.match(hookSource, /useState\(\(\) => \{[\s\S]*?readSidebarCache\(\)/); + assert.doesNotMatch(hookSource, /skeleton|Spinner/i); +}); diff --git a/frontend/tests/sidebar-state.test.ts b/frontend/tests/sidebar-state.test.ts index 11471a02..83342fcc 100644 --- a/frontend/tests/sidebar-state.test.ts +++ b/frontend/tests/sidebar-state.test.ts @@ -1,9 +1,12 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { defaultSidebarOpen, + readStoredSidebarOpen, shouldHandleSidebarShortcut, sidebarViewportForWidth, + writeStoredSidebarOpen, } from "../src/lib/sidebar-state.ts"; import { beginSessionPageLoad, mergeSessionPage } from "../src/lib/session-groups.ts"; import { homeSurface } from "./home-surface.ts"; @@ -47,3 +50,56 @@ test("loadMoreSessions merges by id and only starts one in-flight request", () = assert.deepEqual(merged.map((item) => item.id), ["keep", "next"]); assert.equal(merged[0]?.updatedAt, 900); }); + +/* T5 · the collapse state is remembered across pages (TASK-sidebar-unify D5). + localStorage, not the shadcn `sidebar_state` cookie: `/`, `/chart` and + `/ephemeris` are all static routes, and reading a cookie on the server would + opt every one of them out of static rendering. */ + +function fakeStorage(initial?: string) { + let value = initial; + return { + getItem: () => value ?? null, + setItem: (_key: string, next: string) => { value = next; }, + read: () => value, + }; +} + +test("a collapsed desktop sidebar is still collapsed after a remount", () => { + const storage = fakeStorage(); + + writeStoredSidebarOpen("desktop", false, storage); + assert.equal(storage.read(), "false"); + + // What SidebarProvider resolves on the next page: stored wins over the + // breakpoint default, which for desktop would have been `true`. + assert.equal(readStoredSidebarOpen("desktop", storage) ?? defaultSidebarOpen("desktop"), false); + + writeStoredSidebarOpen("desktop", true, storage); + assert.equal(readStoredSidebarOpen("desktop", storage) ?? defaultSidebarOpen("desktop"), true); + + // Nothing stored, or something unreadable: the breakpoint default stands. + const empty = fakeStorage(); + assert.equal(readStoredSidebarOpen("desktop", empty), null); + assert.equal(readStoredSidebarOpen("tablet", fakeStorage("banana")), null); + assert.equal(readStoredSidebarOpen("tablet", empty) ?? defaultSidebarOpen("tablet"), false); +}); + +test("the mobile drawer is not remembered, read or written", () => { + // Reopening a phone drawer on every navigation is not a preference anyone set. + const storage = fakeStorage(); + writeStoredSidebarOpen("mobile", true, storage); + assert.equal(storage.read(), undefined); + assert.equal(readStoredSidebarOpen("mobile", fakeStorage("true")), null); + assert.equal(readStoredSidebarOpen("mobile", fakeStorage("true")) ?? defaultSidebarOpen("mobile"), false); + + // Unavailable storage (private mode, blocked site data) degrades, never throws. + assert.equal(readStoredSidebarOpen("desktop", null), null); + assert.doesNotThrow(() => writeStoredSidebarOpen("desktop", true, null)); + + // And the provider applies it in the same effect that applied the breakpoint + // default, so a stored value costs no frame the default did not already cost. + const provider = readFileSync(new URL("../src/components/ui/sidebar.tsx", import.meta.url), "utf8"); + assert.match(provider, /readStoredSidebarOpen\(viewport\) \?\? defaultSidebarOpen\(viewport\)/); + assert.match(provider, /writeStoredSidebarOpen\(viewport, nextOpen\)/); +}); diff --git a/frontend/tests/stale-client-recovery.test.ts b/frontend/tests/stale-client-recovery.test.ts index 16d7a1f7..e1f223e2 100644 --- a/frontend/tests/stale-client-recovery.test.ts +++ b/frontend/tests/stale-client-recovery.test.ts @@ -72,7 +72,11 @@ test("the web build stamps the Git SHA as Next deploymentId and does not cache t // routes that actually need a request stay dynamic on their own modules. assert.doesNotMatch(layoutSource, /export const dynamic = "force-dynamic"/); const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8"); - const reportsPage = readFileSync(new URL("../src/app/reports/page.tsx", import.meta.url), "utf8"); + // 原值:`../src/app/reports/page.tsx` + // 新值:`../src/app/(secondary)/reports/page.tsx` + // 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。 + // 路由组括号不进 URL,`/reports` 一字未改;这里改的只是源码位置。 + const reportsPage = readFileSync(new URL("../src/app/(secondary)/reports/page.tsx", import.meta.url), "utf8"); const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8"); assert.match(loginPage, /export const dynamic = "force-dynamic"/); assert.match(reportsPage, /export const dynamic = "force-dynamic"/);