Cap planet captions at 30 degrees with a third ring, fall back only within the current person's sessions, and load self when the people catalog fails.
290 lines
14 KiB
TypeScript
290 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { StrictMode, startTransition, useEffect, useState, type ReactNode } from "react";
|
|
|
|
import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
|
import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts";
|
|
import * as sessionList from "../src/lib/session-list-context.tsx";
|
|
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
|
|
import { resetSubjectCatalogForTests, setCurrentSubject } from "../src/lib/current-subject.ts";
|
|
import { emptyProfile, previewModelCatalog, type Account, type ChatSession } from "../src/lib/home-types.ts";
|
|
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
|
|
|
const account: Account = {
|
|
user: { id: "11111111-1111-4111-8111-111111111111", email: null },
|
|
profile: { ...emptyProfile, name: "Synthetic" },
|
|
avatar: null, credits: 10, isAdmin: false, adminUrl: null,
|
|
rectificationPriceCredits: 0, activeSubscription: null,
|
|
hasConfirmedBirthTime: false, hasUsableBirthTime: false,
|
|
};
|
|
const row: ChatSession = {
|
|
id: "22222222-2222-4222-8222-222222222222", title: "Synthetic session", theme: "general",
|
|
modelId: "test", messages: [], createdAt: 1, updatedAt: 1,
|
|
sessionType: "consultation", rectificationCaseId: null, chartProfileId: null,
|
|
chartProfileName: null, chartProfileRole: null, pinned: false, archivedAt: null,
|
|
messagesHydrated: false,
|
|
};
|
|
const noop = () => {};
|
|
const trigger = { current: null };
|
|
const charts: never[] = [];
|
|
|
|
async function fixture(options: { strict?: boolean; unauthorized?: boolean } = {}) {
|
|
const harness = createClientLifecycleHarness();
|
|
// 原值:只模拟 account / sessions,跨用例未清理人物目录模块缓存。
|
|
// 新值:通过真实 account → chart-profiles → sessions 初始化,并隔离目录状态。
|
|
// 原因:Provider 等人物绑定完成再读历史;不预设 ready,不改外壳/StrictMode 行为断言。
|
|
resetSubjectCatalogForTests();
|
|
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
|
const storageValues = new Map<string, string>();
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
configurable: true,
|
|
value: {
|
|
getItem: (key: string) => storageValues.get(key) ?? null,
|
|
setItem: (key: string, value: string) => { storageValues.set(key, String(value)); },
|
|
removeItem: (key: string) => { storageValues.delete(key); },
|
|
clear: () => { storageValues.clear(); },
|
|
key: (index: number) => Array.from(storageValues.keys())[index] ?? null,
|
|
get length() { return storageValues.size; },
|
|
} satisfies Storage,
|
|
});
|
|
const restoreStorage = () => {
|
|
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
|
|
else Reflect.deleteProperty(globalThis, "localStorage");
|
|
};
|
|
const originalFetch = globalThis.fetch;
|
|
const requests: string[] = [];
|
|
globalThis.fetch = (async (input) => {
|
|
const url = String(input);
|
|
requests.push(url);
|
|
assert.ok(url === "/api/account" || url === "/api/chart-profiles" || url.startsWith("/api/sessions?"), `unexpected fetch ${url}`);
|
|
const payload = url === "/api/account" ? account
|
|
: url === "/api/chart-profiles" ? { profiles: [] }
|
|
: { sessions: [], nextCursor: null };
|
|
return new Response(JSON.stringify(payload), {
|
|
status: options.unauthorized ? 401 : 200,
|
|
});
|
|
}) as typeof fetch;
|
|
let homeRenders = 0;
|
|
let targetCommits = 0;
|
|
let latestList: sessionList.SessionListContextValue | undefined;
|
|
let latestShell: sessionList.ShellRegistration | null = null;
|
|
let latestModel: ReturnType<typeof sessionList.sessionListSidebarModel> | undefined;
|
|
let setPage: (home: boolean) => void = noop;
|
|
let revise: (revision: number) => void = noop;
|
|
const callbackRevisions: number[] = [];
|
|
|
|
function HomeProbe() {
|
|
const list = sessionList.useSessionList();
|
|
const [revision, setRevision] = useState(0);
|
|
// Bound failure even if the old effect/context feedback is reintroduced.
|
|
homeRenders += 1;
|
|
if (homeRenders > 50) throw new Error("Home registration failed to settle within 50 renders");
|
|
const changed = () => { callbackRevisions.push(revision); };
|
|
useHomeShellRegistration({
|
|
account: list.account, accountMenuOpen: revision > 0, accountTrigger: trigger,
|
|
activeSessionId: `revision-${revision}`, cancellationPending: false,
|
|
chartLibrary: charts, creatingSession: false, hydrated: list.settled,
|
|
loadMoreSessions: changed, modalOpen: revision > 0, modelCatalog: null,
|
|
openAccountDialog: changed, pendingSessionId: null,
|
|
profile: { ...emptyProfile, name: `Synthetic ${revision}` },
|
|
rectificationErrorMessage: "", rectificationErrorSessionId: null,
|
|
rectificationOpeningSessionId: null, rectificationSurfaceOpen: false,
|
|
registerShellControls: list.registerShellControls,
|
|
renameSession: changed, selectSession: changed, sessionMenuId: null,
|
|
sessions: list.sessions, sessionsCursor: null,
|
|
setAccountMenuOpen: noop, setPendingSessionDeletion: noop, setSessionMenuId: noop,
|
|
shareSession: changed, startNewChat: changed,
|
|
togglePinnedSession: changed,
|
|
// Like useSessionManagement, intentionally new array/callbacks each render.
|
|
visibleSessions: list.sessions.filter(() => true),
|
|
});
|
|
useEffect(() => { revise = setRevision; }, []);
|
|
return null;
|
|
}
|
|
function Target() {
|
|
useEffect(() => { targetCommits += 1; }, []);
|
|
return null;
|
|
}
|
|
function Shell({ children }: { children: ReactNode }) {
|
|
const list = sessionList.useSessionList();
|
|
const registration = sessionList.useShellRegistration();
|
|
useEffect(() => {
|
|
latestList = list;
|
|
latestShell = registration;
|
|
latestModel = sessionList.sessionListSidebarModel(list, registration);
|
|
});
|
|
// Exercise the actual second provider and unchanged children identity too:
|
|
// shell context updates must not re-render the Home registration producer.
|
|
return <SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
|
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
|
|
</SidebarProvider>;
|
|
}
|
|
function Router() {
|
|
const [home, showHome] = useState(true);
|
|
useEffect(() => { setPage = showHome; }, []);
|
|
return <sessionList.SessionListProvider><Shell>{home ? <HomeProbe /> : <Target />}</Shell></sessionList.SessionListProvider>;
|
|
}
|
|
try {
|
|
await harness.render(options.strict ? <StrictMode><Router /></StrictMode> : <Router />);
|
|
} catch (error) {
|
|
await harness.close();
|
|
globalThis.fetch = originalFetch;
|
|
resetSubjectCatalogForTests();
|
|
restoreStorage();
|
|
throw error;
|
|
}
|
|
return {
|
|
harness, requests, callbackRevisions,
|
|
get renders() { return homeRenders; },
|
|
get commits() { return targetCommits; },
|
|
get list() { assert.ok(latestList); return latestList; },
|
|
get shell() { return latestShell; },
|
|
get model() { assert.ok(latestModel); return latestModel; },
|
|
async navigate(home: boolean) { await harness.update(() => startTransition(() => setPage(home))); },
|
|
async revise(value: number) { await harness.update(() => revise(value)); },
|
|
async close() {
|
|
try { await harness.close(); }
|
|
finally { globalThis.fetch = originalFetch; resetSubjectCatalogForTests(); restoreStorage(); }
|
|
},
|
|
};
|
|
}
|
|
|
|
for (const strict of [false, true]) {
|
|
test(`home registration settles and transitions unregister/reenter (StrictMode=${strict})`, async () => {
|
|
const app = await fixture({ strict });
|
|
try {
|
|
assert.deepEqual(app.harness.errors, []);
|
|
assert.ok(app.shell, "hydrated Home registers its controls");
|
|
const idleRenders = app.renders;
|
|
await app.harness.idle();
|
|
assert.equal(app.renders, idleRenders, "idle shell does not feed updates back into Home");
|
|
assert.ok(idleRenders < 15, `unexpected render growth: ${idleRenders}`);
|
|
const reads = app.requests.length;
|
|
await app.navigate(false);
|
|
assert.deepEqual(app.harness.errors, []);
|
|
assert.ok(app.commits > 0, "startTransition commits the destination");
|
|
assert.equal(app.shell, null, "Home cleanup unregisters controls");
|
|
assert.equal(app.model.account?.name, "Synthetic", "secondary page uses account fallback");
|
|
await app.navigate(true);
|
|
assert.ok(app.shell, "returning Home registers new controls");
|
|
assert.equal(app.requests.length, reads, "shared provider does not refetch on route changes");
|
|
await app.harness.idle();
|
|
assert.deepEqual(app.harness.errors, []);
|
|
} finally { await app.close(); }
|
|
});
|
|
}
|
|
|
|
test("shell receives current callbacks, account and session state without freezing closures", async () => {
|
|
const app = await fixture();
|
|
try {
|
|
assert.deepEqual(app.harness.errors, []);
|
|
const initialCallback = app.shell?.controls.onOpenProfile;
|
|
await app.revise(1);
|
|
assert.equal(app.shell?.activeSessionId, "revision-1");
|
|
assert.equal(app.shell?.insetInert, true);
|
|
assert.equal(app.shell?.escapeBlocked, true);
|
|
assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback);
|
|
app.shell?.controls.onOpenProfile();
|
|
assert.deepEqual(app.callbackRevisions, [1]);
|
|
await app.harness.update(() => {
|
|
app.list.setAccount({ ...account, credits: 27 });
|
|
app.list.setSessions([row]);
|
|
});
|
|
assert.equal(app.model.account?.credits, 27);
|
|
assert.equal(app.model.sessions[0]?.id, row.id);
|
|
await app.navigate(false);
|
|
assert.equal(app.shell, null);
|
|
assert.equal(app.model.account?.credits, 27);
|
|
assert.equal(app.model.sessions[0]?.id, row.id);
|
|
assert.deepEqual(app.harness.errors, []);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
test("401 settles signed-out fallback with no shell registration", async () => {
|
|
const app = await fixture({ unauthorized: true });
|
|
try {
|
|
assert.deepEqual(app.harness.errors, []);
|
|
assert.equal(app.list.signedOut, true);
|
|
assert.equal(app.list.settled, true);
|
|
await app.list.ready;
|
|
assert.equal(app.shell, null);
|
|
assert.deepEqual(app.model.sessions, []);
|
|
assert.equal(app.model.account, null);
|
|
const renders = app.renders;
|
|
await app.harness.idle();
|
|
assert.equal(app.renders, renders);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
|
|
test("provider current-ready gate stays pending through reload and exposes catalog failure without signedOut", async () => {
|
|
const app = await fixture();
|
|
const priorFetch = globalThis.fetch;
|
|
try {
|
|
await app.list.waitUntilReady();
|
|
let release!: (response: Response) => void;
|
|
globalThis.fetch = (input) => String(input).startsWith("/api/sessions?")
|
|
? new Promise(resolve => { release = resolve; }) : priorFetch(input);
|
|
let settled = false;
|
|
await app.harness.update(() => { app.list.reload(); });
|
|
const pending = app.list.waitUntilReady().then(() => { settled = true; });
|
|
await app.harness.idle();
|
|
assert.equal(settled, false);
|
|
assert.equal(app.list.boot(), null);
|
|
await app.harness.update(() => release(Response.json({ sessions: [], nextCursor: null })));
|
|
await pending;
|
|
assert.equal(app.list.boot()?.account?.user.id, account.user.id);
|
|
resetSubjectCatalogForTests();
|
|
globalThis.fetch = async input => String(input) === "/api/chart-profiles"
|
|
? Response.json({}, { status: 503 }) : priorFetch(input);
|
|
await app.harness.update(() => app.list.reload());
|
|
await app.list.waitUntilReady();
|
|
// 原值:人物目录 503 时 boot.error 为 session_list_unavailable,首页进入可恢复错误屏。
|
|
// 新值:目录失败不带 error,账户仍在,signedOut 为 false,历史按本人继续加载。
|
|
// 原因:产品决定人物接口故障降级为本人,不把聊天一起打进错误屏。
|
|
assert.equal(app.list.boot()?.signedOut, false);
|
|
assert.equal(app.list.boot()?.error, undefined);
|
|
assert.equal(app.list.boot()?.account?.user.id, account.user.id);
|
|
} finally { globalThis.fetch = priorFetch; await app.close(); }
|
|
});
|
|
|
|
|
|
test("Home connects to the real provider current generation during person-switch reload", async () => {
|
|
const app = await fixture();
|
|
const priorFetch = globalThis.fetch;
|
|
let release!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
let redirects = 0, done = false, failure = "", phase = "";
|
|
try {
|
|
await app.list.waitUntilReady();
|
|
globalThis.fetch = async input => { await gate; return priorFetch(input); };
|
|
await app.harness.update(() => setCurrentSubject("fictional-person"));
|
|
assert.equal(app.list.boot(), null);
|
|
const commit = new Proxy({
|
|
setAccountError: (value: string) => { failure = value; },
|
|
setBootstrapPhase: (value: string) => { phase = value; },
|
|
}, { get: (target, key) => Reflect.get(target, key) ?? noop }) as HomeBootstrapDeps["commit"];
|
|
const pending = runHomeBootstrap({
|
|
sessionListReady: app.list.ready, waitForSessionList: app.list.waitUntilReady, sessionListBoot: app.list.boot,
|
|
uiPreview: { current: false }, uiPreviewMode: { current: null }, consultationStatusMissingCount: { current: 0 },
|
|
isDevelopment: false, readSearch: () => "", storage: { getItem: () => null, removeItem: noop },
|
|
restoreConsultationRecovery: noop, setComposerNotice: noop, commit,
|
|
io: {
|
|
fetchModelCatalog: async () => previewModelCatalog, fetchActiveConsultationStatus: async () => null,
|
|
redirectToLogin: () => { redirects += 1; throw new Error("unexpected redirect"); },
|
|
resolveLookupBootstrap: async input => ({
|
|
selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false },
|
|
sessions: input.sessions, notice: null,
|
|
}),
|
|
writeSessionUrl: noop, clearLoginSessionReturn: noop, clearStaleClientReload: noop,
|
|
},
|
|
}, new AbortController().signal).then(() => { done = true; });
|
|
await app.harness.idle();
|
|
assert.equal(done, false); assert.equal(redirects, 0);
|
|
await app.harness.update(release);
|
|
await pending;
|
|
assert.equal(done, true); assert.equal(redirects, 0);
|
|
assert.equal(failure, ""); assert.equal(phase, "prepare");
|
|
} finally { globalThis.fetch = priorFetch; await app.close(); }
|
|
});
|