fix(web): 四个页面共用一份会话列表,空会话不入列
对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
This commit is contained in:
@@ -57,7 +57,7 @@ test("a closed account overlay does not render profile or logout content", () =>
|
||||
});
|
||||
|
||||
test("home does not write overlay epoch or chat actions during render", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8");
|
||||
assert.match(overlay, /model: AccountOverlayModel \| null/);
|
||||
assert.doesNotMatch(overlay, /openEpoch/);
|
||||
|
||||
@@ -59,18 +59,21 @@ test("general no-birth-time replies keep a question-specific session title", ()
|
||||
});
|
||||
|
||||
test("daily and topic consultations get distinct dated or domain titles", () => {
|
||||
const at = new Date(2026, 7, 22, 8, 36);
|
||||
// 原值:8月22日 · 今日节奏;同名追加墙钟 08:36;8月22日 · 生时校正
|
||||
// 新值:今日节奏 · 8月22日;同名不再加 HH:MM;生时校正 · 8月22日
|
||||
// 原因:任务书 T4 类别在前,同名靠副标题区分
|
||||
const at = new Date(Date.UTC(2026, 7, 22, 0, 36));
|
||||
assert.equal(
|
||||
resolveSessionTitle("深入看今日", undefined, { entrypoint: "daily_starlanguage", at }),
|
||||
"8月22日 · 今日节奏",
|
||||
"今日节奏 · 8月22日",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSessionTitle("深入看今日", undefined, {
|
||||
entrypoint: "daily_starlanguage",
|
||||
at,
|
||||
existingTitles: ["8月22日 · 今日节奏"],
|
||||
existingTitles: ["今日节奏 · 8月22日"],
|
||||
}),
|
||||
"8月22日 · 今日节奏 08:36",
|
||||
"今日节奏 · 8月22日",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSessionTitle("请帮我梳理目前的事业方向和下一步重点。", undefined, { theme: "career", at }),
|
||||
@@ -81,7 +84,7 @@ test("daily and topic consultations get distinct dated or domain titles", () =>
|
||||
entrypoint: "birth_time_rectification",
|
||||
at,
|
||||
}),
|
||||
"8月22日 · 生时校正",
|
||||
"生时校正 · 8月22日",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const accountRoute = readProjectFile("src/app/api/account/route.ts");
|
||||
const avatarRoute = readProjectFile("src/app/api/account/avatar/route.ts");
|
||||
const avatarComponent = readProjectFile("src/components/user-avatar.tsx");
|
||||
const sidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
const page = readProjectFile("src/app/page.tsx");
|
||||
const page = readProjectFile("src/app/(app)/page.tsx");
|
||||
const profilePanel = readProjectFile("src/components/profile-panel.tsx");
|
||||
const migration = readProjectFile("db/migrations/20260807010000_profile_beam_avatars.sql");
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const panelSource = readProjectFile("src/components/billing-panel.tsx");
|
||||
const billingSource = `${hookSource}\n${panelSource}`;
|
||||
const membershipLib = readProjectFile("src/lib/membership.ts");
|
||||
const nextConfig = readProjectFile("next.config.ts");
|
||||
const pageSource = readProjectFile("src/app/page.tsx");
|
||||
const pageSource = readProjectFile("src/app/(app)/page.tsx");
|
||||
const tabsSource = readProjectFile("src/components/ui/tabs.tsx");
|
||||
|
||||
function countMatches(source: string, pattern: RegExp) {
|
||||
|
||||
@@ -157,7 +157,7 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme
|
||||
});
|
||||
|
||||
test("completed rectification transcript does not repeat the birth place turn", () => {
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(
|
||||
pageSource,
|
||||
|
||||
@@ -17,7 +17,7 @@ const candidateSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
const pageSource = readFileSync(
|
||||
new URL("../src/app/page.tsx", import.meta.url),
|
||||
new URL("../src/app/(app)/page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const storeSource = readFileSync(
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
chartLibraryOnCloudFailure,
|
||||
chartLibrarySessionBranch,
|
||||
} from "../src/lib/chart-library-session.ts";
|
||||
import { sessionSidebarSubtitle, sessionSidebarTitle } from "../src/lib/home-profile.ts";
|
||||
import { sessionSidebarSubtitle, sessionSidebarTitle, toSidebarSessionRow } from "../src/lib/session-sidebar-row.ts";
|
||||
import { emptyProfile, type ChartLibraryRecord, type ChatSession } from "../src/lib/home-types.ts";
|
||||
|
||||
type RecordShape = { id: string; role: "self" | "other" };
|
||||
@@ -57,6 +57,10 @@ test("a failed cloud read keeps only the profile-derived self chart", () => {
|
||||
});
|
||||
|
||||
test("sidebar titles drop the chart-name prefix and only subtitle others", () => {
|
||||
// 原值:本人盘副标题 null;他人盘只有资料名
|
||||
// 新值:一律「M月D日 HH:MM」,他人盘再加「 · 盘主」
|
||||
// 原因:任务书 T4 副标题规则
|
||||
const createdAt = Date.parse("2026-09-14T04:14:00.000Z");
|
||||
const selfRecord: ChartLibraryRecord = {
|
||||
id: "self",
|
||||
role: "self",
|
||||
@@ -71,7 +75,8 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
|
||||
theme: "career",
|
||||
modelId: "m",
|
||||
messages: [],
|
||||
updatedAt: 1,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: "self",
|
||||
@@ -82,9 +87,9 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
|
||||
messagesHydrated: true,
|
||||
};
|
||||
assert.equal(sessionSidebarTitle(base, library), "半年内换工作时机");
|
||||
assert.equal(sessionSidebarSubtitle(base, library), null);
|
||||
assert.equal(sessionSidebarSubtitle(base, library), "9月14日 12:14");
|
||||
const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const };
|
||||
assert.equal(sessionSidebarSubtitle(other, library), "资料已删除 · 对方");
|
||||
assert.equal(sessionSidebarSubtitle(other, library), "9月14日 12:14 · 资料已删除 · 对方");
|
||||
const liveOtherLibrary: ChartLibraryRecord[] = [{
|
||||
...selfRecord,
|
||||
id: "other-1",
|
||||
@@ -92,5 +97,13 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
|
||||
relationship: "partner",
|
||||
profile: { ...emptyProfile, name: "对方" },
|
||||
}];
|
||||
assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "对方");
|
||||
assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "9月14日 12:14 · 对方");
|
||||
assert.deepEqual(toSidebarSessionRow(other, liveOtherLibrary), {
|
||||
id: other.id,
|
||||
title: "半年内换工作时机",
|
||||
subtitle: "9月14日 12:14 · 对方",
|
||||
pinned: false,
|
||||
archived: false,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ async function okView(western: Record<string, unknown> | null = null): Promise<C
|
||||
|
||||
/* 原值:直接 `renderToStaticMarkup(<ChartPageView … />)`
|
||||
新值:外面裹一层 `SidebarProvider`
|
||||
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx`
|
||||
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(app)/layout.tsx`
|
||||
(TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的
|
||||
`SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */
|
||||
function withSidebarProvider(element: React.ReactElement) {
|
||||
@@ -236,7 +236,7 @@ test("the chart page shell is visible before the natal chart arrives", () => {
|
||||
// 新值:断言 46px 顶栏的侧栏触发器在场;侧栏本体改由本文件下一条
|
||||
// 「(secondary) layout 挂一份只读侧栏」断言,读的是 layout 源码
|
||||
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏从每页各一份上移到
|
||||
// `app/(secondary)/layout.tsx`,页面组件的产物里本就不该再有侧栏。
|
||||
// `app/(app)/layout.tsx`,页面组件的产物里本就不该再有侧栏。
|
||||
// 断言主语(这一页不靠一次性「返回对话」链接回去)没变,覆盖没有减少。
|
||||
assert.match(markup, /data-sidebar="trigger"/);
|
||||
assert.doesNotMatch(markup, /返回对话/);
|
||||
@@ -264,23 +264,17 @@ test("a pending western tab uses inline waiting copy instead of a spinner", asyn
|
||||
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>/);
|
||||
test("the (app) layout mounts one sidebar for home and the four secondary routes", () => {
|
||||
// 原值:`(secondary)/layout.tsx` 挂只读侧栏,`useSidebarData` 另拉一份列表
|
||||
// 新值:`(app)/layout.tsx` 一份 SessionListProvider + SidebarProvider,首页也在里面
|
||||
// 原因:TASK-session-list-single-source T2,列表只拉一次、侧栏不卸载
|
||||
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
|
||||
assert.match(layout, /<SessionListProvider>/);
|
||||
assert.match(layout, /<SidebarProvider escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}>/);
|
||||
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"/);
|
||||
assert.equal(layout.match(/<SidebarProvider/g)?.length, 1);
|
||||
assert.match(layout, /controls=\{registration\?\.controls\}/);
|
||||
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
|
||||
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
|
||||
assert.match(provider, /fetch\("\/api\/account"/);
|
||||
});
|
||||
|
||||
@@ -19,12 +19,12 @@ const golden = JSON.parse(
|
||||
const routeSource = readFileSync(new URL("../src/app/api/chart-view/route.ts", import.meta.url), "utf8");
|
||||
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 pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
// 原值:`../src/app/chart/page.tsx`
|
||||
// 新值:`../src/app/(secondary)/chart/page.tsx`
|
||||
// 新值:`../src/app/(app)/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 chartPageSource = readFileSync(new URL("../src/app/(app)/chart/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
const profile = {
|
||||
name: "示例",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { chatReplyAnnouncer } from "../src/lib/chat-reply-announcement.ts";
|
||||
import { RECTIFICATION_STOPPED_NOTICE } from "../src/lib/rectification-surface-state.ts";
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
|
||||
const rectification = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { noticeTone } from "../src/lib/chat-notice.ts";
|
||||
import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
|
||||
import { homeSurface } from "./home-surface.ts";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
|
||||
const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
|
||||
const globalsSource = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
@@ -39,7 +39,7 @@ test("nothing inside the chat panel focuses without preventScroll", () => {
|
||||
// panel through the default scroll-into-view; the transcript has its own
|
||||
// anchor and does not want the browser's.
|
||||
const insidePanel = [
|
||||
"src/app/page.tsx",
|
||||
"src/app/(app)/page.tsx",
|
||||
"src/hooks/use-consultation-run.ts",
|
||||
"src/components/birth-time-choice-question.tsx",
|
||||
"src/components/birth-time-rectification.tsx",
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
|
||||
import { bootstrapPrepareSettled } from "../src/lib/home-bootstrap.ts";
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
||||
const bootstrap = readFileSync(new URL("../src/lib/home-bootstrap.ts", import.meta.url), "utf8");
|
||||
const entrypoint = readFileSync(new URL("../src/lib/consultation-entrypoint.ts", import.meta.url), "utf8");
|
||||
|
||||
@@ -12,14 +12,27 @@ const sql = readFileSync(
|
||||
);
|
||||
const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current"));
|
||||
|
||||
test("session list GET drops empty consultations and still returns a reusable draft", () => {
|
||||
assert.match(listRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.match(listRoute, /\.eq\("session_type", "consultation"\)[\s\S]*\.eq\("messages", \[\]\)/);
|
||||
assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor, draft \}\)/);
|
||||
assert.doesNotMatch(itemRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
});
|
||||
|
||||
test("session list GET omits messages while detail GET returns them", () => {
|
||||
// Former list/detail column strings ended at updated_at; pinned and archived_at
|
||||
// were added when those flags moved off localStorage.
|
||||
// 原值:列表列到 updated_at,pinned,archived_at,不选 created_at
|
||||
// 新值:加上 created_at,供侧栏副标题用会话创建时间
|
||||
// 原因:任务书 T4 副标题是 M月D日 HH:MM(创建时间,Asia/Shanghai)
|
||||
assert.match(
|
||||
listRoute,
|
||||
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
|
||||
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/,
|
||||
);
|
||||
assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/);
|
||||
// 原值:select(SESSION_LIST_COLUMNS) 之后不得出现 messages
|
||||
// 新值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
|
||||
// 原因:T3 空咨询过滤要看 messages,但不把正文带回列表
|
||||
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*messages/);
|
||||
assert.doesNotMatch(
|
||||
listRoute,
|
||||
/SESSION_LIST_COLUMNS = "[^"]*context_summary/,
|
||||
@@ -27,7 +40,7 @@ test("session list GET omits messages while detail GET returns them", () => {
|
||||
assert.match(itemRoute, /export async function GET/);
|
||||
assert.match(
|
||||
itemRoute,
|
||||
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
|
||||
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/,
|
||||
);
|
||||
assert.match(page, /async function fetchSessionDetail\(/);
|
||||
assert.match(page, /async function ensureSessionMessages\(/);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const rectification = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
|
||||
@@ -31,8 +31,11 @@ const postgresFixture = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// 原值:列到 updated_at,pinned,archived_at
|
||||
// 新值:加上 created_at
|
||||
// 原因:任务书 T4 侧栏副标题要用创建时间
|
||||
const SESSION_SELECT =
|
||||
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
|
||||
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at";
|
||||
|
||||
function functionBody(source: string): string {
|
||||
const match = source.match(/as \$\$\r?\n([\s\S]*?)\r?\n\$\$;/);
|
||||
|
||||
@@ -17,7 +17,7 @@ const availability = readFileSync(new URL("src/lib/epay/availability.ts", root),
|
||||
const packagesRoute = readFileSync(new URL("src/app/api/payment/packages/route.ts", root), "utf8");
|
||||
const testRoute = readFileSync(new URL("src/app/api/admin/epay-settings/test/route.ts", root), "utf8");
|
||||
const gatewayPolicy = readFileSync(new URL("src/lib/epay/gateway-policy.ts", root), "utf8");
|
||||
const page = readFileSync(new URL("src/app/page.tsx", root), "utf8");
|
||||
const page = readFileSync(new URL("src/app/(app)/page.tsx", root), "utf8");
|
||||
const billingHook = readFileSync(new URL("src/hooks/use-billing-panel.ts", root), "utf8");
|
||||
const billingPanel = readFileSync(new URL("src/components/billing-panel.tsx", root), "utf8");
|
||||
const billingSource = `${billingHook}\n${billingPanel}`;
|
||||
|
||||
@@ -19,10 +19,10 @@ Object.assign(globalThis, { React });
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8");
|
||||
// 原值:`../src/app/ephemeris/page.tsx`
|
||||
// 新值:`../src/app/(secondary)/ephemeris/page.tsx`
|
||||
// 新值:`../src/app/(app)/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 routePage = readFileSync(new URL("../src/app/(app)/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"));
|
||||
@@ -59,7 +59,7 @@ function payloadFor(date: "2026-09-14" | "2026-09-15", events: EphemerisOkRespon
|
||||
|
||||
/* 原值:直接 `renderToStaticMarkup(<EphemerisView … />)`
|
||||
新值:外面裹一层 `SidebarProvider`
|
||||
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(secondary)/layout.tsx`
|
||||
原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(app)/layout.tsx`
|
||||
(TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的
|
||||
`SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */
|
||||
function withSidebarProvider(element: React.ReactElement) {
|
||||
@@ -80,7 +80,7 @@ function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "20
|
||||
|
||||
test("the ephemeris page is its own route and never grows page.tsx", () => {
|
||||
assert.match(routePage, /from "@\/components\/ephemeris\/ephemeris-page"/);
|
||||
assert.doesNotMatch(readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"), /ephemeris-page/);
|
||||
assert.doesNotMatch(readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"), /ephemeris-page/);
|
||||
// 原值: assert.doesNotMatch(app-sidebar.tsx, /ephemeris/)
|
||||
// 新值: 本单文件归属只锁 page.tsx / 本 route,不再锁 sidebar
|
||||
// 原因: TASK-chart-page-20260915 任务 3 要求 sidebar 同时加「星盘」「星历」入口;文件归属「本单不改 sidebar」不是产品事实「sidebar 不得出现 ephemeris」(BUG-711)
|
||||
|
||||
@@ -20,7 +20,7 @@ import { homeSurface } from "./home-surface.ts";
|
||||
// answer keeps a live marker, because that is content being generated, not data
|
||||
// being loaded.
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
||||
const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
// Measured 2026-09-16 after home-state-lowering batch 2 on origin/staging @ 37e6c519.
|
||||
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1846.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const homeSurfaceFiles = [
|
||||
"../src/app/page.tsx",
|
||||
"../src/app/(app)/page.tsx",
|
||||
"../src/lib/home-types.ts",
|
||||
"../src/components/onboarding-chat-message.tsx",
|
||||
"../src/lib/home-profile.ts",
|
||||
@@ -20,6 +20,10 @@ const optionalHomeHookFiles = [
|
||||
"../src/hooks/use-profile-onboarding.ts",
|
||||
"../src/hooks/use-rectification-surface.ts",
|
||||
"../src/hooks/use-synastry.ts",
|
||||
"../src/hooks/use-session-list.ts",
|
||||
"../src/hooks/use-home-shell-registration.ts",
|
||||
"../src/lib/session-list-context.tsx",
|
||||
"../src/lib/session-list-filter.ts",
|
||||
] as const;
|
||||
|
||||
export const homeSurface = [
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { compilePostgrestOr } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { sessionCursorFilter } from "../src/lib/session-cursor.ts";
|
||||
|
||||
const core = readFileSync(new URL("../src/lib/db/local-postgres-client-core.ts", import.meta.url), "utf8");
|
||||
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
|
||||
test("or() compiles a comma-separated PostgREST filter", () => {
|
||||
const parameters: unknown[] = [];
|
||||
const sql = compilePostgrestOr(
|
||||
"session_type.neq.consultation,messages.neq.[]",
|
||||
parameters,
|
||||
new Map([
|
||||
["session_type", "text"],
|
||||
["messages", "jsonb"],
|
||||
]),
|
||||
);
|
||||
assert.equal(sql, '("session_type" <> $1 or "messages" <> $2)');
|
||||
assert.deepEqual(parameters, ["consultation", "[]"]);
|
||||
assert.match(core, /or\(expression: string\)/);
|
||||
});
|
||||
|
||||
test("or() compiles the session cursor filter with a nested and", () => {
|
||||
const cursor = sessionCursorFilter({
|
||||
updatedAt: "2026-09-03T07:09:23Z",
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
const parameters: unknown[] = [];
|
||||
const sql = compilePostgrestOr(
|
||||
cursor,
|
||||
parameters,
|
||||
new Map([
|
||||
["updated_at", "timestamptz"],
|
||||
["id", "uuid"],
|
||||
]),
|
||||
);
|
||||
assert.equal(
|
||||
sql,
|
||||
'("updated_at" < $1 or ("updated_at" = $2 and "id" < $3))',
|
||||
);
|
||||
assert.deepEqual(parameters, [
|
||||
"2026-09-03T07:09:23Z",
|
||||
"2026-09-03T07:09:23Z",
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
]);
|
||||
assert.match(listRoute, /pageQuery = pageQuery\.or\(sessionCursorFilter\(cursor\)\)/);
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const panelPath = new URL("../src/components/parameter-freeze-panel.tsx", import.meta.url);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const componentSource = readFileSync(
|
||||
new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const sidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
|
||||
const reportCenterSource = readFileSync(
|
||||
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
|
||||
|
||||
@@ -449,7 +449,7 @@ test("the reader renders inside the app shell, in every phase", () => {
|
||||
// 原值:`SecondaryShell` 的 import 与 `<SecondaryShell title="个人报告">` 的出现次数
|
||||
// 新值:`SecondaryHeader` 的 import 与 `<SecondaryHeader title="个人报告" />` 的出现次数
|
||||
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏 + inset 上移到
|
||||
// `app/(secondary)/layout.tsx`,`SecondaryShell` 拆剩 46px 顶栏并改名
|
||||
// `app/(app)/layout.tsx`,`SecondaryShell` 拆剩 46px 顶栏并改名
|
||||
// `SecondaryHeader`。断言主语(每个阶段都在 app 外壳里、都恰好带一次外壳、
|
||||
// 没有一个阶段返回裸 `<main>`)一字未改。
|
||||
assert.match(pageSource, /import \{ SecondaryHeader \} from "@\/components\/secondary-header";/);
|
||||
|
||||
@@ -10,7 +10,7 @@ const sidebar = read("../src/components/app-sidebar.tsx");
|
||||
const sidebarRow = read("../src/components/sidebar-session-row.tsx");
|
||||
const starter = read("../src/components/starter-home.tsx");
|
||||
const styles = read("../src/app/globals.css");
|
||||
const pageSource = read("../src/app/page.tsx");
|
||||
const pageSource = read("../src/app/(app)/page.tsx");
|
||||
const hook = read("../src/hooks/use-rectification-surface.ts");
|
||||
const sessions = read("../src/hooks/use-session-management.ts");
|
||||
const caseService = read("../src/lib/rectification-agentic/v9/case-service.ts");
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("loads birth-time rectification only when its onboarding stage is reached", () => {
|
||||
assert.match(page, /import dynamic from "next\/dynamic"/);
|
||||
|
||||
@@ -17,7 +17,7 @@ const read = (relativePath: string) => readFileSync(new URL(relativePath, import
|
||||
const run = read("../src/hooks/use-consultation-run.ts");
|
||||
const sessions = read("../src/hooks/use-session-management.ts");
|
||||
const consultRoute = read("../src/app/api/consult/route.ts");
|
||||
const pageSource = read("../src/app/page.tsx");
|
||||
const pageSource = read("../src/app/(app)/page.tsx");
|
||||
const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"'));
|
||||
|
||||
test("a rectification session blocks consult send and keeps the draft", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ test("aligns the session transcript and composer to one readable column", () =>
|
||||
});
|
||||
|
||||
test("keeps onboarding transcript and intake card on the same session column", () => {
|
||||
const pageSource = readProjectFile("src/app/page.tsx");
|
||||
const pageSource = readProjectFile("src/app/(app)/page.tsx");
|
||||
assert.match(pageSource, /\$\{!profileComplete \? " is-onboarding" : ""\}/);
|
||||
assert.match(globalStyles, /\.conversation\.is-onboarding \.welcome \{[\s\S]*width:\s*min\(calc\(var\(--session-column-width\) \+ \(var\(--session-column-gutter\) \* 2\)\), 100%\)/);
|
||||
assert.match(globalStyles, /\.conversation\.is-onboarding\.is-empty \.welcome \{[\s\S]*width:\s*min\(var\(--session-column-width\), 100%\)/);
|
||||
@@ -25,7 +25,7 @@ test("keeps onboarding transcript and intake card on the same session column", (
|
||||
});
|
||||
|
||||
test("centers the starter home even when the conversation grid is not is-empty", () => {
|
||||
const pageSource = readProjectFile("src/app/page.tsx");
|
||||
const pageSource = readProjectFile("src/app/(app)/page.tsx");
|
||||
// 原值:空态 `.welcome` 与 `.starter-list` 都是 1040px
|
||||
// 新值:`.welcome` 收到 `--session-column-width`,`.starter-list` 已删
|
||||
// 原因:空态输入框此前 1040px、会话态 760px,首条消息落地时输入框会当场变窄一截。
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
findReusableEmptyConsultation,
|
||||
isListedSidebarSession,
|
||||
} from "../src/lib/session-list-filter.ts";
|
||||
import type { ChatSession } from "../src/lib/home-types.ts";
|
||||
|
||||
const management = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
function session(change: Partial<ChatSession>): ChatSession {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
title: "新对话",
|
||||
theme: "general",
|
||||
modelId: "m",
|
||||
messages: [],
|
||||
updatedAt: 1,
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: null,
|
||||
chartProfileName: null,
|
||||
chartProfileRole: null,
|
||||
pinned: false,
|
||||
archivedAt: null,
|
||||
messagesHydrated: true,
|
||||
...change,
|
||||
};
|
||||
}
|
||||
|
||||
test("an empty hydrated consultation can be reused instead of creating another", () => {
|
||||
const empty = session({ id: "empty" });
|
||||
const listed = session({
|
||||
id: "listed",
|
||||
messagesHydrated: false,
|
||||
title: "半年内换工作时机",
|
||||
});
|
||||
const rectification = session({
|
||||
id: "rect",
|
||||
sessionType: "birth_time_rectification",
|
||||
title: "生时校正 · 9月14日",
|
||||
});
|
||||
assert.equal(findReusableEmptyConsultation([listed, empty, rectification])?.id, "empty");
|
||||
assert.equal(findReusableEmptyConsultation([listed, rectification]), undefined);
|
||||
assert.equal(isListedSidebarSession(empty), false);
|
||||
assert.equal(isListedSidebarSession(listed), true);
|
||||
assert.equal(isListedSidebarSession(rectification), true);
|
||||
assert.match(management, /findReusableEmptyConsultation\(sessions\)/);
|
||||
assert.match(page, /readDraftConsultation\(listBoot\.draftRow/);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
|
||||
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const sessionHook = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
|
||||
test("the app layout owns one SessionListProvider and one SidebarProvider", () => {
|
||||
assert.match(layout, /<SessionListProvider>/);
|
||||
assert.equal(layout.match(/<SessionListProvider>/g)?.length, 1);
|
||||
assert.match(layout, /<SidebarProvider escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}>/);
|
||||
assert.equal(layout.match(/<SidebarProvider/g)?.length, 1);
|
||||
assert.match(layout, /<AppSidebar/);
|
||||
assert.doesNotMatch(page, /<SidebarProvider/);
|
||||
assert.doesNotMatch(page, /<AppSidebar/);
|
||||
});
|
||||
|
||||
test("the provider fetches the session list once and home does not fetch it again", () => {
|
||||
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
|
||||
assert.match(provider, /fetch\("\/api\/account"/);
|
||||
assert.match(page, /await sessionListReady;/);
|
||||
assert.doesNotMatch(page, /fetchSessions\(/);
|
||||
assert.doesNotMatch(page, /fetchAccount\(/);
|
||||
assert.match(page, /useHomeShellRegistration\(/);
|
||||
});
|
||||
|
||||
test("a 401 clears the list and marks signedOut", () => {
|
||||
assert.match(provider, /sessionResponse\.status === 401 \|\| accountResponse\.status === 401/);
|
||||
assert.match(provider, /signedOut: true/);
|
||||
assert.doesNotMatch(provider, /localStorage|sessionStorage|document\.cookie|indexedDB/i);
|
||||
});
|
||||
|
||||
test("session writes no longer invalidate a second cache", () => {
|
||||
assert.doesNotMatch(sessionHook, /invalidateSidebarCache/);
|
||||
});
|
||||
@@ -82,6 +82,6 @@ test("cloud lookup and session management call the server before clearing ?c=",
|
||||
assert.match(pop, /lookupSessionById/);
|
||||
assert.match(pop, /SESSION_LOOKUP_FAILED_NOTICE/);
|
||||
assert.match(pop, /status === "missing"/);
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
assert.match(page, /resolveLookupBootstrap/);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ test("shouldGenerateSessionTitle only runs on a first-round auto title", () => {
|
||||
assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "consultation" }, [{ role: "user" }]), false);
|
||||
assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "birth_time_rectification" }, []), false);
|
||||
assert.equal(shouldGenerateSessionTitle({ title: "9月6日 · 今日节奏", sessionType: "consultation" }, []), false);
|
||||
assert.equal(shouldGenerateSessionTitle({ title: "今日节奏 · 9月6日", sessionType: "consultation" }, []), false);
|
||||
assert.equal(shouldGenerateSessionTitle({ title: "半年内换工作时机", sessionType: "consultation" }, []), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ test("keeps app sidebar props as product data and callbacks", () => {
|
||||
|
||||
test("removes the user-facing admin entry and routes credit control to membership", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
const page = readProjectFile("src/app/page.tsx");
|
||||
const page = readProjectFile("src/app/(app)/page.tsx");
|
||||
|
||||
assert.doesNotMatch(appSidebar, /adminUrl|account\.isAdmin|后台管理|KeyRound/);
|
||||
assert.doesNotMatch(page, /admin-button|ShieldCheck|后台管理/);
|
||||
@@ -307,20 +307,24 @@ test("removes the user-facing admin entry and routes credit control to membershi
|
||||
});
|
||||
|
||||
test("composes the chat page with the app sidebar shell", () => {
|
||||
const page = readProjectFile("src/app/page.tsx");
|
||||
// 原值:page.tsx 自己挂 SidebarProvider / AppSidebar / SidebarInset
|
||||
// 新值:外壳在 (app)/layout.tsx;首页只注册 controls 并保留 SidebarTrigger
|
||||
// 原因:T2 列表只拉一次、四页共用一份侧栏
|
||||
const page = readProjectFile("src/app/(app)/page.tsx");
|
||||
const layout = readProjectFile("src/app/(app)/layout.tsx");
|
||||
const shell = readProjectFile("src/hooks/use-home-shell-registration.ts");
|
||||
|
||||
assert.match(page, /const modalOpen = activeAccountDialog !== null \|\| onboardingPaywallOpen/);
|
||||
assert.match(page, /<SidebarProvider escapeBlocked=\{accountMenuOpen \|\| modalOpen\}>/);
|
||||
assert.match(page, /<main className="chat-app">[\s\S]*<AppSidebar\b/);
|
||||
assert.match(
|
||||
page,
|
||||
/<SidebarInset className=\{`chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}`\} inert=\{modalOpen\}>/,
|
||||
);
|
||||
assert.match(layout, /<SidebarProvider escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}>/);
|
||||
assert.match(layout, /<main className="chat-app">[\s\S]*<AppSidebar\b/);
|
||||
assert.match(layout, /insetClassName/);
|
||||
assert.match(shell, /insetClassName: `chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}`/);
|
||||
assert.match(page, /<SidebarTrigger placement="inset" \/>/);
|
||||
assert.doesNotMatch(page, /<SidebarProvider/);
|
||||
});
|
||||
|
||||
test("removes page-local mobile sidebar ownership", () => {
|
||||
const page = readProjectFile("src/app/page.tsx");
|
||||
const page = readProjectFile("src/app/(app)/page.tsx");
|
||||
|
||||
assert.doesNotMatch(page, /mobileSidebarOpen|setMobileSidebarOpen/);
|
||||
assert.doesNotMatch(page, /className="sidebar-backdrop"/);
|
||||
@@ -329,10 +333,12 @@ test("removes page-local mobile sidebar ownership", () => {
|
||||
});
|
||||
|
||||
test("blocks the provider mobile Escape action behind layered account UI", () => {
|
||||
const page = readProjectFile("src/app/page.tsx");
|
||||
const layout = readProjectFile("src/app/(app)/layout.tsx");
|
||||
const shell = readProjectFile("src/hooks/use-home-shell-registration.ts");
|
||||
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
|
||||
|
||||
assert.match(page, /escapeBlocked=\{accountMenuOpen \|\| modalOpen\}/);
|
||||
assert.match(shell, /escapeBlocked: accountMenuOpen \|\| modalOpen/);
|
||||
assert.match(layout, /escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}/);
|
||||
assert.match(sidebar, /event\.key === "Escape" && isMobile && openMobile && !escapeBlocked/);
|
||||
});
|
||||
|
||||
@@ -392,7 +398,7 @@ test("changes sidebar state without transition frames", () => {
|
||||
});
|
||||
|
||||
test("keeps the chat title in the flexible left-aligned header column", () => {
|
||||
const page = readProjectFile("src/app/page.tsx");
|
||||
const page = readProjectFile("src/app/(app)/page.tsx");
|
||||
const header = page.slice(page.indexOf('<header className="chat-header">'), page.indexOf("</header>", page.indexOf('<header className="chat-header">')));
|
||||
|
||||
assert.match(cssBlock(".chat-header"), /grid-template-columns:\s*auto\s+minmax\(0,\s*1fr\)\s+auto/);
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "
|
||||
const siteStyles = readFileSync(new URL("../src/app/site-styles.ts", import.meta.url), "utf8");
|
||||
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
|
||||
const adminCss = readFileSync(new URL("../src/app/admin/admin.css", import.meta.url), "utf8");
|
||||
const homePage = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const homePage = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("Inter is loaded through next/font and applied as a CSS variable", () => {
|
||||
@@ -54,7 +54,12 @@ test("site chrome owns globals.css; admin does not import it", () => {
|
||||
assert.match(siteStyles, /import "\.\/globals\.css"/);
|
||||
assert.match(siteStyles, /import "\.\/birth-time-choice\.css"/);
|
||||
assert.doesNotMatch(layout, /globals\.css|birth-time-choice\.css|site-styles/);
|
||||
assert.match(homePage, /import "@\/app\/site-styles"/);
|
||||
// 原值:首页 page.tsx import site-styles
|
||||
// 新值:(app)/layout.tsx import,首页不再各自引进
|
||||
// 原因:T2 四页共用一份外壳
|
||||
const appLayout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
|
||||
assert.match(appLayout, /import "\.\.\/site-styles"/);
|
||||
assert.doesNotMatch(homePage, /site-styles/);
|
||||
assert.match(loginPage, /import "\.\.\/site-styles"/);
|
||||
assert.doesNotMatch(adminLayout, /globals\.css|site-styles/);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
shouldReloadFrozenBootstrap,
|
||||
} from "../src/lib/stale-client-recovery.ts";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const layoutSource = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
|
||||
const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8");
|
||||
const recoverySource = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
|
||||
@@ -73,10 +73,10 @@ test("the web build stamps the Git SHA as Next deploymentId and does not cache t
|
||||
assert.doesNotMatch(layoutSource, /export const dynamic = "force-dynamic"/);
|
||||
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
|
||||
// 原值:`../src/app/reports/page.tsx`
|
||||
// 新值:`../src/app/(secondary)/reports/page.tsx`
|
||||
// 新值:`../src/app/(app)/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 reportsPage = readFileSync(new URL("../src/app/(app)/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