新增 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
100 lines
4.1 KiB
TypeScript
100 lines
4.1 KiB
TypeScript
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);
|
|
});
|