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:
co-authored by
Claude Fable 5.1
parent
d9d347236f
commit
fb77c86585
@@ -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<string, unknown> | null = null): Promise<C
|
||||
return result.body as ChartViewOk;
|
||||
}
|
||||
|
||||
/* 原值:直接 `renderToStaticMarkup(<ChartPageView … />)`
|
||||
新值:外面裹一层 `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 我的报告", () => {
|
||||
// 原值:三个页面项的中文标签直接写在 `<SidebarHeader>` 的 JSX 里,顺序用
|
||||
// `header.indexOf(">星盘<")` 之类量。
|
||||
// 新值:标签收进模块常量 `NAV_PAGES`,头部只剩 `NAV_PAGES.map(...)`;顺序改在
|
||||
// 常量里量,并另外断言这一段仍排在「新建对话」之后。
|
||||
// 原因:TASK-sidebar-unify T1 把次级页那份侧栏收编进同一个组件,三项在两种模式下
|
||||
// 必须逐字一致,写两遍就会再次分叉。断言主语(新建对话 → 星盘 → 星历 →
|
||||
// 我的报告,且「我的报告」未改名)一字未改。
|
||||
const header = sidebar.slice(sidebar.indexOf("<SidebarHeader"), sidebar.indexOf("</SidebarHeader>"));
|
||||
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` 渲染成 `<SidebarMenuLink href=…>`,并追加
|
||||
// 反向断言:侧栏里不得再出现 `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, />星盘</);
|
||||
// 原值:断言页面上有「返回对话」链接
|
||||
// 新值:断言常驻侧栏在场(「新建对话」是它的第一项)
|
||||
// 原因:星盘页并入 app 外壳后侧栏常驻,回对话不再依赖一个一次性链接;
|
||||
// 这比原断言更强——它要求的是整条导航都在,而不只是一个返回按钮。
|
||||
assert.match(markup, /新建对话/);
|
||||
// 原值:`assert.match(markup, /新建对话/)` —— 在页面组件的产物里断言侧栏第一项
|
||||
// 新值:断言 46px 顶栏的侧栏触发器在场;侧栏本体改由本文件下一条
|
||||
// 「(secondary) layout 挂一份只读侧栏」断言,读的是 layout 源码
|
||||
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏从每页各一份上移到
|
||||
// `app/(secondary)/layout.tsx`,页面组件的产物里本就不该再有侧栏。
|
||||
// 断言主语(这一页不靠一次性「返回对话」链接回去)没变,覆盖没有减少。
|
||||
assert.match(markup, /data-sidebar="trigger"/);
|
||||
assert.doesNotMatch(markup, /返回对话/);
|
||||
assert.match(markup, /这一张盘还没拿到/);
|
||||
assert.doesNotMatch(markup, /正在加载|骨架|spinner|打开即有/i);
|
||||
@@ -208,7 +239,7 @@ test("the chart page shell is visible before the natal chart arrives", () => {
|
||||
});
|
||||
|
||||
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<typeof CHART_VIEW_LAYERS[number]>(["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, /<SidebarProvider>/);
|
||||
assert.match(layout, /<AppSidebar\b/);
|
||||
assert.match(layout, /<SidebarInset className="chat-panel secondary-panel">/);
|
||||
assert.equal(layout.match(/<SidebarProvider>/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"/);
|
||||
});
|
||||
|
||||
@@ -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: "示例",
|
||||
|
||||
@@ -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;星盘 / 星历 / 我的报告由侧栏自己用 `<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/);
|
||||
|
||||
// 原值: 4 条 membershipHref 软跳 /membership
|
||||
// 新值: 账单入口改 openAccountDialog("billing");离开对话的星盘/星历/报告走 document load
|
||||
// 原因: 对话用 history.pushState 写 ?c=,App Router 的 router.push 无法离开首页
|
||||
assert.match(pageSource, /onOpenReports=\{\(\) => 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 <Link> now.
|
||||
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
|
||||
assert.doesNotMatch(pageSource, /window\.location\.assign\(membershipHref\(/);
|
||||
});
|
||||
|
||||
@@ -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(<EphemerisView … />)`
|
||||
新值:外面裹一层 `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,
|
||||
|
||||
@@ -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"\)\}/)`
|
||||
// 新值:断言侧栏自己用 `<SidebarMenuLink href="/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
|
||||
|
||||
@@ -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 与 `<SecondaryShell title="个人报告">` 的出现次数
|
||||
// 新值:`SecondaryHeader` 的 import 与 `<SecondaryHeader title="个人报告" />` 的出现次数
|
||||
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏 + inset 上移到
|
||||
// `app/(secondary)/layout.tsx`,`SecondaryShell` 拆剩 46px 顶栏并改名
|
||||
// `SecondaryHeader`。断言主语(每个阶段都在 app 外壳里、都恰好带一次外壳、
|
||||
// 没有一个阶段返回裸 `<main>`)一字未改。
|
||||
assert.match(pageSource, /import \{ SecondaryHeader \} from "@\/components\/secondary-header";/);
|
||||
// No phase may return a bare <main>: that would nest inside the shell's own.
|
||||
assert.doesNotMatch(pageSource, /<main className="personal-report-(?:state|reader)"/);
|
||||
const shells = pageSource.match(/<SecondaryShell title="个人报告">/g) ?? [];
|
||||
const shells = pageSource.match(/<SecondaryHeader title="个人报告" \/>/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}`);
|
||||
|
||||
@@ -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, /加载更多|没有更多/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SIDEBAR_CACHE_TTL_MS,
|
||||
invalidateSidebarCache,
|
||||
readSidebarCache,
|
||||
sidebarCacheIsFresh,
|
||||
writeSidebarCache,
|
||||
} from "../src/lib/sidebar-data-cache.ts";
|
||||
|
||||
const hookSource = readFileSync(new URL("../src/hooks/use-sidebar-data.ts", import.meta.url), "utf8");
|
||||
const sessionHookSource = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
|
||||
const row = (id: string, title: string) => ({
|
||||
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<SidebarDataState>\(\(\) => \{[\s\S]*?readSidebarCache\(\)/);
|
||||
assert.doesNotMatch(hookSource, /skeleton|Spinner/i);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
Reference in New Issue
Block a user